11#! /usr/bin/env bash
2- # pr-status.sh — Fetch status of open PRs for a user (REST API) .
2+ # pr-status.sh — Fetch status of open PRs for a user via gh CLI .
33# Single run, no TUI. "New" comments = posted after the PR's last commit.
44#
5- # Rate-limit aware:
6- # - ETag caching (304 = free, no quota cost)
7- # - Per-PR updated_at diffing (skip detail fetches for unchanged PRs)
8- # - Tracks X-RateLimit-Remaining, outputs footer + recommended interval
5+ # Uses gh CLI for all API access (no GITHUB_TOKEN needed).
6+ # Per-PR updated_at caching to skip detail fetches for unchanged PRs.
97#
108# Usage:
119# pr-status.sh --repo edge-react-gui [--owner EdgeApp] [--user Jon-edge] [--format text|json]
1210# pr-status.sh # All repos for user in EdgeApp org
1311# pr-status.sh --user Jon-edge # All repos for specific user in EdgeApp org
14- # pr-status.sh --budget 0.5 # Reserve 50% of rate limit for other tools
1512#
16- # Requires: GITHUB_TOKEN env var , node.
13+ # Requires: gh CLI (authenticated) , node.
1714set -euo pipefail
1815
19- OWNER=" EdgeApp" REPO=" " USER=" " FORMAT=" text" BUDGET= " 0.67 "
16+ OWNER=" EdgeApp" REPO=" " USER=" " FORMAT=" text"
2017while [[ $# -gt 0 ]]; do
2118 case " $1 " in
2219 --owner) OWNER=" $2 " ; shift 2 ;;
2320 --repo) REPO=" $2 " ; shift 2 ;;
2421 --user) USER=" $2 " ; shift 2 ;;
2522 --format) FORMAT=" $2 " ; shift 2 ;;
26- --budget) BUDGET=" $2 " ; shift 2 ;;
2723 * ) echo " Unknown arg: $1 " >&2 ; exit 1 ;;
2824 esac
2925done
3026
31- [[ -z " ${GITHUB_TOKEN:- } " ]] && { echo " Error: GITHUB_TOKEN not set." >&2 ; exit 2; }
27+ command -v gh & > /dev/null || { echo " Error: gh CLI not found. Install: https://cli.github.com" >&2 ; exit 2; }
28+ gh auth status & > /dev/null 2>&1 || { echo " Error: gh not authenticated. Run: gh auth login" >&2 ; exit 2; }
3229
3330STATE_DIR=" ${TMPDIR:-/ tmp} /pr-watch-${OWNER} -${REPO:- all} "
3431mkdir -p " $STATE_DIR "
3532export STATE_DIR
3633
3734exec node -e '
38- const https = require("https ")
35+ const { execFile } = require("child_process ")
3936const fs = require("fs")
40- const crypto = require("crypto")
41- const { OWNER, REPO, USER, FORMAT, BUDGET } = {
37+ const { OWNER, REPO, USER, FORMAT } = {
4238 OWNER: process.argv[1],
4339 REPO: process.argv[2] || "",
4440 USER: process.argv[3],
45- FORMAT: process.argv[4],
46- BUDGET: parseFloat(process.argv[5]) || 0.67
41+ FORMAT: process.argv[4]
4742}
48- const TOKEN = process.env.GITHUB_TOKEN
4943const STATE_DIR = process.env.STATE_DIR
5044
51- // --- Rate limit tracking ---
52- let rateLimitRemaining = null
53- let rateLimitLimit = null
54- let rateLimitReset = null
5545let apiCallCount = 0
56- let cacheHitCount = 0
5746
58- // --- ETag + body caching ---
59- function cacheKey(path) {
60- return crypto.createHash("md5").update(path).digest("hex").substring(0, 12)
61- }
62-
63- function loadEtag(path) {
64- try { return fs.readFileSync(`${STATE_DIR}/etag-${cacheKey(path)}`, "utf8").trim() } catch { return null }
65- }
66-
67- function saveEtag(path, etag) {
68- if (etag) fs.writeFileSync(`${STATE_DIR}/etag-${cacheKey(path)}`, etag)
69- }
70-
71- function loadCachedBody(path) {
72- try { return JSON.parse(fs.readFileSync(`${STATE_DIR}/body-${cacheKey(path)}.json`, "utf8")) } catch { return null }
73- }
74-
75- function saveCachedBody(path, body) {
76- fs.writeFileSync(`${STATE_DIR}/body-${cacheKey(path)}.json`, JSON.stringify(body))
77- }
78-
79- function ghFetch(path) {
80- return new Promise((resolve, reject) => {
81- const headers = {
82- Authorization: `Bearer ${TOKEN}`,
83- Accept: "application/vnd.github+json",
84- "User-Agent": "pr-status"
85- }
86- const etag = loadEtag(path)
87- if (etag) headers["If-None-Match"] = etag
88-
89- https.get({ hostname: "api.github.com", path, headers }, res => {
90- // Track rate limit from every response
91- const rl = res.headers["x-ratelimit-remaining"]
92- const rlLimit = res.headers["x-ratelimit-limit"]
93- const rlReset = res.headers["x-ratelimit-reset"]
94- if (rl != null) rateLimitRemaining = Math.min(rateLimitRemaining ?? Infinity, parseInt(rl, 10))
95- if (rlLimit != null) rateLimitLimit = parseInt(rlLimit, 10)
96- if (rlReset != null) rateLimitReset = parseInt(rlReset, 10)
97-
98- if (res.statusCode === 304) {
99- cacheHitCount++
100- const cached = loadCachedBody(path)
101- resolve(cached)
102- return
103- }
104-
105- apiCallCount++
106- let data = ""
107- res.on("data", c => (data += c))
108- res.on("end", () => {
109- const newEtag = res.headers["etag"]
110- saveEtag(path, newEtag)
111- try {
112- const body = JSON.parse(data)
113- saveCachedBody(path, body)
114- resolve(body)
115- } catch { resolve(null) }
116- })
117- }).on("error", reject)
47+ function ghFetch(path, extraArgs) {
48+ return new Promise((resolve) => {
49+ apiCallCount++
50+ const args = ["api", path]
51+ if (extraArgs) args.push(...extraArgs)
52+ execFile("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
53+ if (err) { resolve(null); return }
54+ try { resolve(JSON.parse(stdout)) } catch { resolve(null) }
55+ })
11856 })
11957}
12058
@@ -135,6 +73,20 @@ function savePrNumbers(numbers) {
13573 fs.writeFileSync(`${STATE_DIR}/known-prs.json`, JSON.stringify(numbers))
13674}
13775
76+ // --- Concurrency limiter ---
77+ async function pool(items, concurrency, fn) {
78+ const results = new Array(items.length)
79+ let next = 0
80+ async function worker() {
81+ while (next < items.length) {
82+ const i = next++
83+ results[i] = await fn(items[i], i)
84+ }
85+ }
86+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
87+ return results
88+ }
89+
13890// --- Utilities ---
13991function relTime(iso) {
14092 if (!iso) return "-"
@@ -161,7 +113,6 @@ async function main() {
161113
162114 const previousPrNumbers = loadPreviousPrNumbers()
163115
164- // Collect PRs: either from one repo or search across org
165116 let prs
166117 if (REPO) {
167118 const allPRs = await ghFetch(`/repos/${OWNER}/${REPO}/pulls?state=open&per_page=30`)
@@ -173,44 +124,38 @@ async function main() {
173124 .filter(p => p.user.login === user)
174125 .map(p => ({ ...p, _repo: REPO }))
175126 } else {
176- // Search all repos in org for open PRs by user
177127 const q = encodeURIComponent(`type:pr state:open author:${user} org:${OWNER}`)
178128 const search = await ghFetch(`/search/issues?q=${q}&per_page=50&sort=updated&order=desc`)
179129 if (!search?.items) {
180130 process.stderr.write("API error searching PRs\n")
181131 process.exit(1)
182132 }
183- // Search results lack head sha; fetch full PR objects
184- prs = await Promise.all(search.items.map(async item => {
133+ prs = await pool(search.items, 4, async item => {
185134 const repo = item.repository_url.split("/").pop()
186135 const full = await ghFetch(`/repos/${OWNER}/${repo}/pulls/${item.number}`)
187136 return { ...full, _repo: repo }
188- }))
137+ })
189138 }
190139
191- // Track which PRs are new
192140 const currentPrNumbers = prs.map(p => p.number)
193141 const newPrNumbers = new Set(currentPrNumbers.filter(n => !previousPrNumbers.includes(n)))
194142 savePrNumbers(currentPrNumbers)
195143
196144 let changedPrCount = 0
197145
198- const results = await Promise.all (prs.map( async pr => {
146+ const results = await pool (prs, 4, async pr => {
199147 const repo = pr._repo
200148 const n = pr.number
201149 const sha = pr.head.sha
202150 const updatedAt = pr.updated_at
203151
204- // Check if PR has changed since last poll
205152 const cached = loadPrCache(n)
206153 if (cached && cached.updatedAt === updatedAt && !newPrNumbers.has(n)) {
207- // Unchanged — use cached result, but update isNew flag
208154 return { ...cached.result, isNew: false }
209155 }
210156
211157 changedPrCount++
212158
213- // Fetch in parallel: inline comments, issue comments, check runs, commits, reviews
214159 const [inline, issue, checks, commits, reviews] = await Promise.all([
215160 ghFetch(`/repos/${OWNER}/${repo}/pulls/${n}/comments?per_page=100`),
216161 ghFetch(`/repos/${OWNER}/${repo}/issues/${n}/comments?per_page=100`),
@@ -219,14 +164,12 @@ async function main() {
219164 ghFetch(`/repos/${OWNER}/${repo}/pulls/${n}/reviews?per_page=100`)
220165 ])
221166
222- // Last commit timestamp
223167 const commitList = Array.isArray(commits) ? commits : []
224168 const lastCommit = commitList.length > 0 ? commitList[commitList.length - 1] : null
225169 const lastCommitDate = lastCommit?.commit?.committer?.date
226170 || lastCommit?.commit?.author?.date
227171 || null
228172
229- // All comments by others
230173 const allComments = [
231174 ...(Array.isArray(inline) ? inline : [])
232175 .filter(c => c.user?.login !== user)
@@ -236,7 +179,6 @@ async function main() {
236179 .map(c => ({ id: c.id, user: c.user?.login, body: c.body?.substring(0, 120), at: c.created_at, type: "issue" }))
237180 ].sort((a, b) => b.at.localeCompare(a.at))
238181
239- // Split into new (after last commit) and old
240182 const newComments = lastCommitDate
241183 ? allComments.filter(c => c.at > lastCommitDate)
242184 : []
@@ -246,7 +188,6 @@ async function main() {
246188
247189 const checkRuns = checks?.check_runs || []
248190
249- // Review approval status — dedupe to latest review per human user
250191 const reviewList = Array.isArray(reviews) ? reviews : []
251192 const latestByUser = {}
252193 for (const r of reviewList) {
@@ -291,17 +232,21 @@ async function main() {
291232
292233 savePrCache(n, result, updatedAt)
293234 return result
294- }))
235+ })
236+
237+ // Fetch rate limit info
238+ const rateLimit = await ghFetch("/rate_limit")
239+ const rateLimitRemaining = rateLimit?.resources?.core?.remaining ?? null
240+ const rateLimitLimit = rateLimit?.resources?.core?.limit ?? null
241+ const rateLimitReset = rateLimit?.resources?.core?.reset ?? null
295242
296- // Calculate recommended interval
297- const callsPerPoll = 1 + (changedPrCount > 0 ? changedPrCount * 5 : prs.length * 5)
243+ const callsPerPoll = apiCallCount
298244 const secsUntilReset = rateLimitReset ? Math.max(1, rateLimitReset - Math.floor(Date.now() / 1000)) : 3600
299- const budgetCalls = rateLimitRemaining != null ? Math.floor(rateLimitRemaining * BUDGET ) : 2500
245+ const budgetCalls = rateLimitRemaining != null ? Math.floor(rateLimitRemaining * 0.67 ) : 2500
300246 const recommendedInterval = budgetCalls > 0 ? Math.max(30, Math.ceil(secsUntilReset / (budgetCalls / callsPerPoll))) : 300
301247
302248 const meta = {
303249 apiCalls: apiCallCount,
304- cacheHits: cacheHitCount,
305250 changedPrs: changedPrCount,
306251 rateLimitRemaining,
307252 rateLimitLimit,
@@ -449,9 +394,8 @@ async function main() {
449394 const rlInfo = rateLimitRemaining != null
450395 ? `API: ${rateLimitRemaining}/${rateLimitLimit} remaining`
451396 : "API: unknown"
452- const cacheInfo = `${apiCallCount} calls, ${cacheHitCount} cached`
453397 out.push(`${D}${LINE}${R}`)
454- out.push(`${D}${rlInfo} | ${cacheInfo} | next: ${recommendedInterval}s${R}`)
398+ out.push(`${D}${rlInfo} | ${apiCallCount} calls | next: ${recommendedInterval}s${R}`)
455399
456400 // Machine-readable line for pr-watch.sh to parse
457401 out.push(`# interval:${recommendedInterval}`)
@@ -460,4 +404,4 @@ async function main() {
460404}
461405
462406main().catch(e => { process.stderr.write("Error: " + e.message + "\n"); process.exit(1) })
463- ' " $OWNER " " $REPO " " $USER " " $FORMAT " " $BUDGET "
407+ ' " $OWNER " " $REPO " " $USER " " $FORMAT "
0 commit comments