Skip to content

Commit 73ece9a

Browse files
GiggleLiuclaude
andcommitted
feat: add board cache to eliminate redundant fetches in watch loops
Add --board-cache flag to pipeline_board.py CLI that caches board data to a file for up to 120 seconds. watch_and_dispatch now shares a single board fetch between request_copilot_reviews and poll_project_items within each loop iteration, eliminating a redundant API call. Also fix batch_issue_fetcher to not fire when tests inject a custom issue_fetcher (avoids hitting the real API in tests). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3a280c4 commit 73ece9a

3 files changed

Lines changed: 69 additions & 19 deletions

File tree

scripts/make_helpers.sh

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,14 @@ run_agent() {
6161
# --- Project board ---
6262

6363
# Detect the next eligible item and preserve retryable state in a queue.
64-
# poll_project_items <mode> <state-file> [repo] [number] [format]
64+
# poll_project_items <mode> <state-file> [repo] [number] [format] [board-cache]
6565
poll_project_items() {
6666
mode=$1
6767
state_file=$2
6868
repo=${3-}
6969
number=${4-}
7070
fmt=${5-text}
71+
board_cache=${6-}
7172

7273
set -- scripts/pipeline_board.py next "$mode" "$state_file" --format "$fmt"
7374
if [ -n "$repo" ]; then
@@ -76,6 +77,9 @@ poll_project_items() {
7677
if [ -n "$number" ]; then
7778
set -- "$@" --number "$number"
7879
fi
80+
if [ -n "$board_cache" ]; then
81+
set -- "$@" --board-cache "$board_cache"
82+
fi
7983
python3 "$@"
8084
}
8185

@@ -205,10 +209,15 @@ cleanup_pipeline_worktree() {
205209
}
206210

207211
# Request Copilot review on all Review pool PRs that don't have one yet.
208-
# request_copilot_reviews <repo>
212+
# request_copilot_reviews <repo> [board-cache]
209213
request_copilot_reviews() {
210214
repo=$1
211-
prs=$(python3 scripts/pipeline_board.py list review --repo "$repo" --format json \
215+
board_cache=${2-}
216+
cache_args=""
217+
if [ -n "$board_cache" ]; then
218+
cache_args="--board-cache $board_cache"
219+
fi
220+
prs=$(python3 scripts/pipeline_board.py list review --repo "$repo" --format json $cache_args \
212221
| python3 -c "
213222
import sys, json
214223
data = json.load(sys.stdin)
@@ -235,16 +244,20 @@ watch_and_dispatch() {
235244
interval=${POLL_INTERVAL:-600}
236245

237246
state_file=$(mktemp /tmp/problemreductions-${mode}-state.XXXXXX)
238-
trap 'rm -f "$state_file"' EXIT INT TERM
247+
board_cache="/tmp/problemreductions-${mode}-board-cache.json"
248+
trap 'rm -f "$state_file" "$board_cache"' EXIT INT TERM
239249

240250
echo "Watching for new ${label} (polling every $((interval / 60))m)..."
241251
while true; do
252+
# Invalidate board cache at the start of each iteration
253+
rm -f "$board_cache"
254+
242255
# For review mode, request Copilot reviews on PRs that don't have one yet
243256
if [ "$mode" = "review" ] && [ -n "$repo" ]; then
244-
request_copilot_reviews "$repo"
257+
request_copilot_reviews "$repo" "$board_cache"
245258
fi
246259

247-
next_item=$(poll_project_items "$mode" "$state_file" "$repo")
260+
next_item=$(poll_project_items "$mode" "$state_file" "$repo" "" text "$board_cache")
248261
status=$?
249262
if [ "$status" -eq 0 ]; then
250263
item_id=$(printf '%s\n' "$next_item" | cut -f1)

scripts/pipeline_board.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import subprocess
99
import sys
10+
import time
1011
from collections import Counter
1112
from datetime import datetime, timezone
1213
from pathlib import Path
@@ -70,8 +71,29 @@ def run_gh(*args: str) -> str:
7071
return subprocess.check_output(["gh", *args], text=True)
7172

7273

73-
def fetch_board_items(owner: str, project_number: int, limit: int) -> dict:
74-
return json.loads(
74+
def fetch_board_items(
75+
owner: str,
76+
project_number: int,
77+
limit: int,
78+
*,
79+
cache_file: Path | None = None,
80+
cache_max_age: float = 120,
81+
) -> dict:
82+
"""Fetch project board items, optionally using a file cache.
83+
84+
When *cache_file* is set and the file exists and is younger than
85+
*cache_max_age* seconds, the cached JSON is returned without an API call.
86+
Otherwise the board is fetched from GitHub and written to the cache file.
87+
"""
88+
if cache_file is not None:
89+
try:
90+
age = time.time() - cache_file.stat().st_mtime
91+
if age < cache_max_age:
92+
return json.loads(cache_file.read_text())
93+
except (FileNotFoundError, json.JSONDecodeError):
94+
pass
95+
96+
data = json.loads(
7597
run_gh(
7698
"project",
7799
"item-list",
@@ -85,6 +107,12 @@ def fetch_board_items(owner: str, project_number: int, limit: int) -> dict:
85107
)
86108
)
87109

110+
if cache_file is not None:
111+
cache_file.parent.mkdir(parents=True, exist_ok=True)
112+
cache_file.write_text(json.dumps(data))
113+
114+
return data
115+
88116

89117
def fetch_pr_reviews(repo: str, pr_number: int) -> list[dict]:
90118
data = json.loads(run_gh("api", f"repos/{repo}/pulls/{pr_number}/reviews"))
@@ -1130,6 +1158,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
11301158
next_parser.add_argument("--limit", type=int, default=500)
11311159
next_parser.add_argument("--number", type=int)
11321160
next_parser.add_argument("--format", choices=["text", "json"], default="text")
1161+
next_parser.add_argument("--board-cache", type=Path, default=None)
11331162

11341163
claim_parser = subparsers.add_parser("claim-next")
11351164
claim_parser.add_argument("mode", choices=["ready", "review"])
@@ -1142,6 +1171,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
11421171
claim_parser.add_argument("--format", choices=["text", "json"], default="json")
11431172
claim_parser.add_argument("--project-id", default=PROJECT_ID)
11441173
claim_parser.add_argument("--field-id", default=STATUS_FIELD_ID)
1174+
claim_parser.add_argument("--board-cache", type=Path, default=None)
11451175

11461176
ack_parser = subparsers.add_parser("ack")
11471177
ack_parser.add_argument("state_file", type=Path)
@@ -1154,6 +1184,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
11541184
list_parser.add_argument("--project-number", type=int, default=8)
11551185
list_parser.add_argument("--limit", type=int, default=500)
11561186
list_parser.add_argument("--format", choices=["text", "json"], default="text")
1187+
list_parser.add_argument("--board-cache", type=Path, default=None)
11571188

11581189
move_parser = subparsers.add_parser("move")
11591190
move_parser.add_argument("item_id")
@@ -1183,7 +1214,7 @@ def main(argv: list[str] | None = None) -> int:
11831214
if args.command == "claim-next":
11841215
if args.mode == "review" and not args.repo:
11851216
raise SystemExit("--repo is required in claim-next review mode")
1186-
board_data = fetch_board_items(args.owner, args.project_number, args.limit)
1217+
board_data = fetch_board_items(args.owner, args.project_number, args.limit, cache_file=args.board_cache)
11871218
claim_result = claim_next_entry(
11881219
args.mode,
11891220
board_data,
@@ -1205,7 +1236,7 @@ def main(argv: list[str] | None = None) -> int:
12051236
if args.command == "list":
12061237
if args.mode == "review" and not args.repo:
12071238
raise SystemExit("--repo is required in list review mode")
1208-
board_data = fetch_board_items(args.owner, args.project_number, args.limit)
1239+
board_data = fetch_board_items(args.owner, args.project_number, args.limit, cache_file=args.board_cache)
12091240
if args.mode == "ready":
12101241
items = status_items(board_data, STATUS_READY)
12111242
return print_candidate_list(args.mode, items, fmt=args.format)
@@ -1234,7 +1265,7 @@ def main(argv: list[str] | None = None) -> int:
12341265
if args.mode in {"review", "final-review"} and not args.repo:
12351266
raise SystemExit(f"--repo is required in {args.mode} mode")
12361267

1237-
board_data = fetch_board_items(args.owner, args.project_number, args.limit)
1268+
board_data = fetch_board_items(args.owner, args.project_number, args.limit, cache_file=args.board_cache)
12381269
next_item = select_next_entry(
12391270
args.mode,
12401271
board_data,

scripts/pipeline_skill_context.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,8 +1001,11 @@ def build_project_pipeline_context(
10011001
existing_problem_finder: Callable[[Path], set[str]] | None = None,
10021002
) -> dict:
10031003
board_fetcher = board_fetcher or fetch_project_board_data
1004+
_custom_issue_fetcher = issue_fetcher is not None
10041005
issue_fetcher = issue_fetcher or pipeline_checks.fetch_issue
1005-
batch_issue_fetcher = batch_issue_fetcher or pipeline_board.batch_fetch_issues
1006+
# Only use batch fetcher when no custom per-item fetcher was injected (e.g. tests)
1007+
if batch_issue_fetcher is None and not _custom_issue_fetcher:
1008+
batch_issue_fetcher = pipeline_board.batch_fetch_issues
10061009
existing_problem_finder = existing_problem_finder or scan_existing_problems
10071010

10081011
board_data = board_fetcher(repo)
@@ -1022,14 +1025,17 @@ def build_project_pipeline_context(
10221025
key=lambda pair: pair[1]["issue_number"],
10231026
)
10241027

1025-
# Batch-fetch all issue data in one API call
1026-
all_issue_numbers = [int(entry["issue_number"]) for _, entry in ready_entries_items]
1027-
issues_cache = batch_issue_fetcher(repo, all_issue_numbers)
1028+
# Batch-fetch all issue data in one API call when batch fetcher is available
1029+
if batch_issue_fetcher is not None:
1030+
all_issue_numbers = [int(entry["issue_number"]) for _, entry in ready_entries_items]
1031+
issues_cache = batch_issue_fetcher(repo, all_issue_numbers)
10281032

1029-
def _fetch_one(repo: str, n: int) -> dict:
1030-
if n in issues_cache:
1031-
return issues_cache[n]
1032-
return issue_fetcher(repo, n)
1033+
def _fetch_one(repo: str, n: int) -> dict:
1034+
if n in issues_cache:
1035+
return issues_cache[n]
1036+
return issue_fetcher(repo, n)
1037+
else:
1038+
_fetch_one = issue_fetcher
10331039

10341040
ready_issues = [
10351041
classify_project_issue(

0 commit comments

Comments
 (0)