Skip to content

Commit 09a8058

Browse files
committed
Rewrite pr-status.sh to use gh CLI instead of GITHUB_TOKEN
Replace raw https API calls with gh api via child_process.execFile, removing the GITHUB_TOKEN dependency. Drop ETag caching (gh handles internally), add concurrency limiter for parallel PR fetches, and fetch rate limit info via /rate_limit endpoint.
1 parent ec7de04 commit 09a8058

File tree

1 file changed

+47
-103
lines changed

1 file changed

+47
-103
lines changed

.cursor/commands/pr-status.sh

Lines changed: 47 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,58 @@
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.
1714
set -euo pipefail
1815

19-
OWNER="EdgeApp" REPO="" USER="" FORMAT="text" BUDGET="0.67"
16+
OWNER="EdgeApp" REPO="" USER="" FORMAT="text"
2017
while [[ $# -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
2925
done
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

3330
STATE_DIR="${TMPDIR:-/tmp}/pr-watch-${OWNER}-${REPO:-all}"
3431
mkdir -p "$STATE_DIR"
3532
export STATE_DIR
3633

3734
exec node -e '
38-
const https = require("https")
35+
const { execFile } = require("child_process")
3936
const 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
4943
const STATE_DIR = process.env.STATE_DIR
5044
51-
// --- Rate limit tracking ---
52-
let rateLimitRemaining = null
53-
let rateLimitLimit = null
54-
let rateLimitReset = null
5545
let 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 ---
13991
function 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
462406
main().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

Comments
 (0)