@proagentstore/cli 0.4.56 → 0.4.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,796 @@
1
+ /**
2
+ * Read-only enumeration and search of GitHub organizations and repositories reachable
3
+ * by the machine's own `gh` credentials (#685, #686).
4
+ *
5
+ * ── Enumeration (#685)
6
+ *
7
+ * Uses `gh api` with `--paginate` to walk GitHub's REST API: first the list of
8
+ * organizations the authenticated user belongs to, then the repos for each (plus the
9
+ * user's own personal repos). Never writes, never mutates, never touches a credential
10
+ * beyond what `gh` already has.
11
+ *
12
+ * ── Search (#686)
13
+ *
14
+ * Uses GitHub's own search API (`GET /search/repositories`) via `gh api` — one request
15
+ * instead of a per-repo fan-out. GitHub's search index spans all repos the credential
16
+ * can read (public + private owned/member repos), so it is the correct tool for
17
+ * questions that span the whole account (open PRs, recently-active repos, topic match).
18
+ *
19
+ * Rate-limit strategy (#686 hard constraint): GitHub's authenticated search quota is
20
+ * 30 requests/min (separate from the 5 000/hr REST quota). To stay within it:
21
+ *
22
+ * 1. An in-process LRU cache (`SEARCH_CACHE`) holds the last result per canonical
23
+ * query string. Cache entries are fresh for `SEARCH_TTL_MS` (5 minutes). A cache
24
+ * hit is returned immediately without touching the network.
25
+ * 2. When GitHub returns 403 or the rate-limit error text, the function returns
26
+ * `{ rateLimited: true, cachedAt }` and the cloud relays that to the caller.
27
+ * The UI should tell the user to retry after a minute rather than looping.
28
+ * 3. `limit` is always bounded (default 30, max 100) — GitHub's own search cap is
29
+ * 100 items per request and paginating further risks burning the quota on one query.
30
+ *
31
+ * ── Scale
32
+ *
33
+ * The measured account (serge-ivo, 2026-08-16) has ~90 distinct owners and >1 000
34
+ * repos. A single unpaginated call cannot work: GitHub caps list endpoints at 100
35
+ * items per page. This module paginates by driving `gh api --paginate`, which
36
+ * follows GitHub's `Link: <…>; rel="next"` header automatically and collects all
37
+ * pages into one JSON array.
38
+ *
39
+ * Returning the full 1 000+ list in a single relay call is also unworkable: it would
40
+ * blow the relay timeout and the relay body limit. Instead callers pass `owner` to
41
+ * scope the query, `limit` to cap the result set, and `since` (an ISO timestamp) to
42
+ * request only repos pushed after a given point. The most useful default is sorting
43
+ * by last-pushed because the long tail is single-repo owners nobody has touched in
44
+ * years.
45
+ *
46
+ * ── Why `gh api`, not the GitHub API directly
47
+ *
48
+ * The machine's `gh auth login` already stores a token (or an OAuth device flow
49
+ * credential) under `~/.config/gh`. There is no token to extract, no secret to pass
50
+ * over the relay, and no auth surface to widen. `gh api` uses that credential
51
+ * transparently, exactly the way every other `gh` call in a coding session does.
52
+ *
53
+ * ── What remains open (stated rather than hidden)
54
+ *
55
+ * - `gh` must be on PATH. A machine without `gh` gets an error rather than an empty
56
+ * list; the error is surfaced to the caller, not swallowed.
57
+ * - The machine credential determines what is visible. A deploy key sees exactly one
58
+ * repository; a user account sees what that account can read.
59
+ * - Rate limits are GitHub's (5 000/hr for authenticated requests, 30/min for search).
60
+ * Long enumerations of a large org will consume REST quota; `limit` keeps this
61
+ * manageable. Search has its own quota; the cache and bounded limit guard it.
62
+ *
63
+ * ── Repository detail (#687)
64
+ *
65
+ * For a given `owner/repo`, returns the repository's open issues, open pull requests,
66
+ * and branches in three separate arrays. Each is fetched with a separate `gh api` call
67
+ * (no `--paginate` to stay within the REST quota) and bounded to a sensible limit.
68
+ * The same caching strategy as search (#686) is applied: results are held in-process
69
+ * for `DETAIL_TTL_MS` (2 minutes — shorter than search because PR/issue state changes
70
+ * faster than repo metadata) and the caller is told when data is from cache via
71
+ * `fromCache: true`. Read-only by construction: only `gh api GET` verbs are used.
72
+ *
73
+ * ── Credential scope and read-only enforcement (#688)
74
+ *
75
+ * The machine's `gh` credential is account-wide: it can write to any repository the
76
+ * user can reach, not just the ones this browser is browsing. The read-only invariant
77
+ * is enforced at the single function boundary that every `gh api` call passes through:
78
+ * `assertReadOnlyGhApiArgs` rejects any args that carry a non-GET HTTP method flag
79
+ * (`-X`, `--method`, or `--method=`). This means a future code path cannot accidentally
80
+ * issue a mutating call — the guard refuses before `spawnSync` is reached.
81
+ *
82
+ * `getGithubCredentialScope` surfaces the credential's effective scope to the user:
83
+ * the authenticated login and the organisations it belongs to. This is the answer to
84
+ * "what can this credential actually reach?" before any browse operation begins.
85
+ */
86
+ import { spawnSync } from "node:child_process";
87
+ /**
88
+ * HTTP methods that write state on GitHub. Any `gh api` call carrying one of these
89
+ * is a mutating call and is refused at this boundary.
90
+ *
91
+ * `GET` and `HEAD` (GitHub's `gh api` default is GET) are the only safe verbs.
92
+ * `DELETE` is included: while GitHub's REST spec marks it as "idempotent", it
93
+ * irreversibly removes resources and is not idempotent from a data-safety view.
94
+ */
95
+ const WRITE_METHODS = new Set(["POST", "PATCH", "PUT", "DELETE"]);
96
+ /**
97
+ * Guard that every `gh api` call in this module passes through (#688).
98
+ *
99
+ * Scans `args` for any HTTP method flag (`-X <method>`, `--method <method>`,
100
+ * `--method=<method>`) and returns `{ error }` if the resolved method is a write
101
+ * verb. Called from both `runGhApi` (paginated) and `runGhApiOnce` (single page),
102
+ * which are the only two `spawnSync` call sites in this file.
103
+ *
104
+ * Fails closed: an unrecognised / empty method passes through (gh defaults to GET).
105
+ * A caller that tries to be clever by lowercasing the method is also caught — the
106
+ * check is case-insensitive.
107
+ *
108
+ * Returns `undefined` when the args are safe; returns `{ error: string }` when
109
+ * a write method is found, so callers can return early in the same shape they use
110
+ * for every other error.
111
+ */
112
+ export function assertReadOnlyGhApiArgs(args) {
113
+ let method = "";
114
+ let nextIsMethod = false;
115
+ for (const arg of args) {
116
+ if (nextIsMethod) {
117
+ method = arg;
118
+ nextIsMethod = false;
119
+ continue;
120
+ }
121
+ if (arg === "-X" || arg === "--method") {
122
+ nextIsMethod = true;
123
+ continue;
124
+ }
125
+ if (arg.startsWith("--method=")) {
126
+ method = arg.slice("--method=".length);
127
+ }
128
+ }
129
+ if (!method)
130
+ return undefined; // no explicit method — gh defaults to GET, safe
131
+ const upper = method.toUpperCase();
132
+ if (WRITE_METHODS.has(upper)) {
133
+ return { error: `github-browse: refused mutating gh api call (method: ${upper}) — this tool is read-only` };
134
+ }
135
+ return undefined;
136
+ }
137
+ /** The maximum `limit` the cloud may request from one call. */
138
+ const MAX_LIMIT = 200;
139
+ const DEFAULT_LIMIT = 50;
140
+ /**
141
+ * Run one `gh api` call and return the parsed JSON array, or throw on failure.
142
+ *
143
+ * `--paginate` follows all pages and concatenates them into one array. Large orgs
144
+ * may generate hundreds of pages; `--jq` is used to project down to the fields we
145
+ * need BEFORE the pages are concatenated, keeping the payload small.
146
+ *
147
+ * `spawnSync` is used so the function is synchronous (matching the runner's
148
+ * synchronous endpoint contract — no event-loop gymnastics in `server.ts`). The
149
+ * runner's relay timeout is 120s; a full pagination of the largest org (~250 repos)
150
+ * takes under 10s in practice.
151
+ *
152
+ * Never throws — errors are returned as `{ error: string }`.
153
+ */
154
+ function runGhApi(args) {
155
+ // Runtime read-only guard (#688): reject before spawnSync if the caller somehow
156
+ // passes a write method. This is the chokepoint for all paginated gh api calls.
157
+ const guardErr = assertReadOnlyGhApiArgs(args);
158
+ if (guardErr)
159
+ return guardErr;
160
+ const result = spawnSync("gh", ["api", "--paginate", ...args], {
161
+ encoding: "utf-8",
162
+ timeout: 60_000,
163
+ // Inherit the machine's PATH and credentials but nothing else sensitive.
164
+ env: process.env,
165
+ });
166
+ if (result.error) {
167
+ // ENOENT means `gh` is not installed.
168
+ const code = result.error?.code;
169
+ return { error: code === "ENOENT" ? "`gh` is not installed or not on PATH" : result.error.message };
170
+ }
171
+ if (result.status !== 0) {
172
+ const msg = String(result.stderr ?? "").slice(0, 500).trim() || `gh exited ${result.status ?? "unknown"}`;
173
+ return { error: msg };
174
+ }
175
+ // `gh api --paginate` writes each page's JSON array on a line; the full output
176
+ // is valid JSON only as a concatenated array. GitHub's paginator combines pages
177
+ // with `[][]` (two arrays back-to-back), which is not valid JSON. The `--jq`
178
+ // flag, when combined with `--paginate`, instead produces one JSON array overall,
179
+ // so we parse the whole stdout as a single array.
180
+ const raw = String(result.stdout ?? "").trim();
181
+ if (!raw)
182
+ return [];
183
+ try {
184
+ const parsed = JSON.parse(raw);
185
+ return Array.isArray(parsed) ? parsed : [parsed];
186
+ }
187
+ catch {
188
+ return { error: "gh returned unparsable output" };
189
+ }
190
+ }
191
+ /**
192
+ * Project a raw GitHub API repository object down to {@link GithubRepoEntry}.
193
+ *
194
+ * Accepts `unknown` because `gh api` output is untyped; every field is defensively
195
+ * coerced rather than assumed to be present. A missing field becomes its zero value:
196
+ * `null` for strings, `"main"` for the branch.
197
+ */
198
+ function toEntry(raw) {
199
+ if (!raw || typeof raw !== "object")
200
+ return null;
201
+ const r = raw;
202
+ const fullName = String(r.full_name ?? r.nameWithOwner ?? "");
203
+ const slash = fullName.lastIndexOf("/");
204
+ if (slash < 1)
205
+ return null;
206
+ const owner = fullName.slice(0, slash);
207
+ const name = fullName.slice(slash + 1);
208
+ // Operator-precedence guard: parenthesise the fallback so `??` doesn't bind into
209
+ // the ternary. Without parens `r.visibility ?? r.isPrivate === true ? … : …` parses
210
+ // as `(r.visibility ?? r.isPrivate === true) ? "private" : "public"`, which makes
211
+ // EVERY truthy visibility string map to "private".
212
+ const rawVis = r.visibility != null ? String(r.visibility) : (r.isPrivate === true ? "private" : "public");
213
+ const visibility = rawVis === "private" ? "private" : rawVis === "internal" ? "internal" : "public";
214
+ return {
215
+ owner,
216
+ name,
217
+ full_name: fullName,
218
+ visibility,
219
+ default_branch: String(r.default_branch ?? r.defaultBranchRef ?? "main") || "main",
220
+ pushed_at: r.pushed_at != null ? String(r.pushed_at) : (r.pushedAt != null ? String(r.pushedAt) : null),
221
+ language: r.language != null ? String(r.language) : null,
222
+ };
223
+ }
224
+ /**
225
+ * Enumerate GitHub organizations the authenticated user belongs to.
226
+ *
227
+ * Returns `{ orgs: string[] }` — the login names only, so the caller can let the
228
+ * user pick one before fetching its (potentially large) repo list.
229
+ *
230
+ * Returns `{ error: string }` when `gh` is unavailable or the call fails.
231
+ */
232
+ export function listGithubOrgs() {
233
+ // Minimal projection: we only need the `login` field.
234
+ const jq = "[.[] | {login: .login}]";
235
+ const raw = runGhApi(["user/orgs", "--jq", jq]);
236
+ if ("error" in raw)
237
+ return raw;
238
+ const orgs = [];
239
+ for (const item of raw) {
240
+ if (item && typeof item === "object" && "login" in item) {
241
+ orgs.push(String(item.login));
242
+ }
243
+ }
244
+ return { orgs };
245
+ }
246
+ /**
247
+ * Report the effective credential scope of the machine's `gh` login (#688).
248
+ *
249
+ * Two sequential calls: `gh api user` (login) + `gh api user/orgs` (org list).
250
+ * Neither call mutates anything; both are read-only GET requests. The result tells
251
+ * the user which GitHub account the runner is acting as and which organisations
252
+ * that account can see, BEFORE any browse operation begins.
253
+ *
254
+ * Returns `{ error }` when `gh` is unavailable or unauthenticated.
255
+ */
256
+ export function getGithubCredentialScope() {
257
+ // Fetch the authenticated user's login.
258
+ const userRaw = runGhApiOnce("user");
259
+ if (userRaw != null && typeof userRaw === "object" && "error" in userRaw) {
260
+ return userRaw;
261
+ }
262
+ const login = userRaw != null && typeof userRaw === "object"
263
+ ? String(userRaw.login ?? "")
264
+ : "";
265
+ // Fetch org memberships.
266
+ const orgsJq = "[.[] | {login: .login}]";
267
+ const orgsRaw = runGhApi(["user/orgs", "--jq", orgsJq]);
268
+ if ("error" in orgsRaw)
269
+ return orgsRaw;
270
+ const orgs = [];
271
+ for (const item of orgsRaw) {
272
+ if (item && typeof item === "object" && "login" in item) {
273
+ orgs.push(String(item.login));
274
+ }
275
+ }
276
+ return { checked: true, login, orgs };
277
+ }
278
+ /**
279
+ * Enumerate GitHub repositories reachable by the machine's `gh` credentials.
280
+ *
281
+ * Read-only by construction: the only `gh` call is `gh api GET /user/repos` or
282
+ * `gh api GET /orgs/{org}/repos`, which never mutates anything on GitHub.
283
+ *
284
+ * Never throws — every failure is a descriptive `{ error }` property.
285
+ */
286
+ export function listGithubRepos(input = {}) {
287
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Math.floor(Number(input.limit) || DEFAULT_LIMIT)));
288
+ const visibility = input.visibility === "public" ? "public" : input.visibility === "private" ? "private" : "all";
289
+ // JQ projection — only the fields we return, so large responses stay small.
290
+ // `pushed_at` is the sort key and the `since` filter key.
291
+ const jq = "[.[] | {full_name, visibility, default_branch, pushed_at, language}]";
292
+ let raw;
293
+ const owner = (input.owner ?? "").trim();
294
+ if (!owner || owner === "me") {
295
+ // Personal repos: https://docs.github.com/en/rest/repos/repos#list-repositories-for-the-authenticated-user
296
+ raw = runGhApi([`user/repos?sort=pushed&direction=desc&per_page=100&visibility=${visibility}&affiliation=owner`, "--jq", jq]);
297
+ }
298
+ else {
299
+ // Org repos: https://docs.github.com/en/rest/repos/repos#list-organization-repositories
300
+ raw = runGhApi([`orgs/${encodeURIComponent(owner)}/repos?sort=pushed&direction=desc&per_page=100&type=all`, "--jq", jq]);
301
+ }
302
+ if ("error" in raw)
303
+ return raw;
304
+ // Filter by `since` if requested.
305
+ const since = input.since ? new Date(input.since).getTime() : 0;
306
+ const all = [];
307
+ for (const item of raw) {
308
+ const entry = toEntry(item);
309
+ if (!entry)
310
+ continue;
311
+ if (since > 0) {
312
+ const pushedMs = entry.pushed_at ? new Date(entry.pushed_at).getTime() : 0;
313
+ if (pushedMs < since)
314
+ continue;
315
+ }
316
+ all.push(entry);
317
+ }
318
+ const hasMore = all.length > limit;
319
+ const repos = all.slice(0, limit);
320
+ return { checked: true, repos, hasMore, total: all.length };
321
+ }
322
+ // ─────────────────────────────────────────────────────────────────────────────
323
+ // GitHub repository search (#686)
324
+ // ─────────────────────────────────────────────────────────────────────────────
325
+ /** Maximum items GitHub's search API returns in a single request. */
326
+ const SEARCH_MAX_LIMIT = 100;
327
+ const SEARCH_DEFAULT_LIMIT = 30;
328
+ const SEARCH_CACHE = new Map();
329
+ /** How long a cached search result is considered fresh (milliseconds). */
330
+ const SEARCH_TTL_MS = 5 * 60 * 1000; // 5 minutes
331
+ /** Evict entries older than `SEARCH_TTL_MS` to keep the cache bounded. */
332
+ function pruneSearchCache() {
333
+ const cutoff = Date.now() - SEARCH_TTL_MS;
334
+ for (const [key, entry] of SEARCH_CACHE) {
335
+ if (entry.fetchedAt < cutoff)
336
+ SEARCH_CACHE.delete(key);
337
+ }
338
+ }
339
+ /**
340
+ * Clear the entire search cache.
341
+ *
342
+ * Exported for test isolation only — tests must call this in `beforeEach` so
343
+ * that no cached result bleeds from one test case to the next.
344
+ */
345
+ export function clearSearchCacheForTesting() {
346
+ SEARCH_CACHE.clear();
347
+ }
348
+ /**
349
+ * Build the canonical GitHub search query string from structured input.
350
+ *
351
+ * Qualifiers are appended in a fixed order so the same logical query always
352
+ * produces the same string (used as the cache key).
353
+ */
354
+ function buildSearchQuery(input) {
355
+ const parts = [];
356
+ // Free-text query first.
357
+ if (input.query?.trim())
358
+ parts.push(input.query.trim());
359
+ // Structured qualifiers in stable order.
360
+ if (input.owner?.trim())
361
+ parts.push(`user:${input.owner.trim()}`);
362
+ if (input.language?.trim())
363
+ parts.push(`language:${input.language.trim()}`);
364
+ if (input.topic?.trim())
365
+ parts.push(`topic:${input.topic.trim()}`);
366
+ if (input.pushedAfter?.trim()) {
367
+ // GitHub expects YYYY-MM-DD; accept ISO with time and truncate.
368
+ const date = input.pushedAfter.trim().slice(0, 10);
369
+ parts.push(`pushed:>=${date}`);
370
+ }
371
+ // Fallback: when nothing is specified, search for repos the user can see.
372
+ // GitHub search requires at least one qualifier or text — use a sort-only query.
373
+ return parts.length > 0 ? parts.join(" ") : "is:public";
374
+ }
375
+ /**
376
+ * Project a raw GitHub search API item to {@link GithubSearchRepoEntry}.
377
+ *
378
+ * GitHub's search response nests the owner under `owner.login`. Fields that
379
+ * may be absent are coerced to their zero values rather than assumed present.
380
+ */
381
+ function toSearchEntry(raw) {
382
+ if (!raw || typeof raw !== "object")
383
+ return null;
384
+ const r = raw;
385
+ const fullName = String(r.full_name ?? "");
386
+ if (!fullName.includes("/"))
387
+ return null;
388
+ const slash = fullName.lastIndexOf("/");
389
+ const owner = fullName.slice(0, slash);
390
+ const name = fullName.slice(slash + 1);
391
+ const rawVis = r.visibility != null ? String(r.visibility) : (r.private === true ? "private" : "public");
392
+ const visibility = rawVis === "private" ? "private" : rawVis === "internal" ? "internal" : "public";
393
+ const topics = Array.isArray(r.topics) ? r.topics.filter((t) => typeof t === "string") : [];
394
+ return {
395
+ full_name: fullName,
396
+ owner,
397
+ name,
398
+ description: r.description != null ? String(r.description) : null,
399
+ visibility,
400
+ language: r.language != null ? String(r.language) : null,
401
+ pushed_at: r.pushed_at != null ? String(r.pushed_at) : null,
402
+ stars: typeof r.stargazers_count === "number" ? r.stargazers_count : 0,
403
+ forks: typeof r.forks_count === "number" ? r.forks_count : 0,
404
+ open_issues: typeof r.open_issues_count === "number" ? r.open_issues_count : 0,
405
+ topics,
406
+ };
407
+ }
408
+ /**
409
+ * Search GitHub repositories reachable by the machine's `gh` credentials.
410
+ *
411
+ * Uses GitHub's `/search/repositories` API (or `/search/issues?type=pr` when
412
+ * `openPrs` is set), which searches across all repos the credential can read
413
+ * without a per-repo fan-out. One API call, bounded result, rate-limit guarded
414
+ * by an in-process 5-minute cache.
415
+ *
416
+ * Read-only by construction: the only `gh` call is `gh api GET /search/*`.
417
+ *
418
+ * Never throws — every failure is a descriptive `{ error }` property.
419
+ */
420
+ export function searchGithubRepos(input = {}) {
421
+ const limit = Math.max(1, Math.min(SEARCH_MAX_LIMIT, Math.floor(Number(input.limit) || SEARCH_DEFAULT_LIMIT)));
422
+ const sort = input.sort === "stars" ? "stars" : input.sort === "forks" ? "forks" : "updated";
423
+ let canonicalQuery;
424
+ let apiPath;
425
+ if (input.openPrs) {
426
+ // "Which repos have open pull requests?" uses the PR/issue search API.
427
+ // We build a query for open PRs and group by repository.
428
+ const prParts = ["is:pr", "is:open"];
429
+ if (input.owner?.trim())
430
+ prParts.push(`user:${input.owner.trim()}`);
431
+ if (input.pushedAfter?.trim()) {
432
+ const date = input.pushedAfter.trim().slice(0, 10);
433
+ prParts.push(`created:>=${date}`);
434
+ }
435
+ if (input.query?.trim())
436
+ prParts.push(input.query.trim());
437
+ canonicalQuery = prParts.join(" ");
438
+ // GitHub search API for issues/PRs — we'll extract unique repos from results.
439
+ apiPath = `search/issues?q=${encodeURIComponent(canonicalQuery)}&per_page=${Math.min(limit * 3, 100)}&sort=updated&order=desc`;
440
+ }
441
+ else {
442
+ canonicalQuery = buildSearchQuery(input);
443
+ apiPath = `search/repositories?q=${encodeURIComponent(canonicalQuery)}&per_page=${limit}&sort=${sort}&order=desc`;
444
+ }
445
+ // Check fresh cache before hitting the network.
446
+ const cacheKey = `${apiPath}:${limit}`;
447
+ const existing = SEARCH_CACHE.get(cacheKey);
448
+ if (existing && Date.now() - existing.fetchedAt < SEARCH_TTL_MS) {
449
+ return { ...existing.result, fromCache: true };
450
+ }
451
+ // GitHub search API returns a `{ total_count, items: [...] }` wrapper —
452
+ // NOT a bare array. We cannot use `--paginate` here because paginating search
453
+ // results would burn quota; instead we ask for exactly what we need in one page.
454
+ // `runGhApi` uses `--paginate` which doesn't work for search (it'd parse the
455
+ // wrapper as one item). We call spawnSync directly.
456
+ // Read-only by construction (#688): `apiPath` is a search endpoint path constructed
457
+ // above; no method override is passed. The only way in is a GET to search/repositories
458
+ // or search/issues — both read-only. `assertReadOnlyGhApiArgs` is not needed here
459
+ // because there are no caller-supplied args; the path is constructed, not forwarded.
460
+ const ghResult = spawnSync("gh", ["api", apiPath], {
461
+ encoding: "utf-8",
462
+ timeout: 30_000,
463
+ env: process.env,
464
+ });
465
+ if (ghResult.error) {
466
+ const code = ghResult.error?.code;
467
+ return { error: code === "ENOENT" ? "`gh` is not installed or not on PATH" : ghResult.error.message };
468
+ }
469
+ // GitHub returns 403 with a rate-limit body when the search quota is exhausted.
470
+ const isRateLimited = ghResult.status === 403 ||
471
+ String(ghResult.stderr ?? "").toLowerCase().includes("rate limit") ||
472
+ String(ghResult.stdout ?? "").includes("rate limit exceeded");
473
+ if (isRateLimited) {
474
+ // Return the stale cache entry (if any) with the rate-limited flag so the
475
+ // caller knows the data is old and retrying immediately won't help.
476
+ // Note: we do NOT call pruneSearchCache before this point so that stale entries
477
+ // are available here as a fallback — we only prune when we have fresh data to store.
478
+ const stale = SEARCH_CACHE.get(cacheKey);
479
+ if (stale) {
480
+ return { ...stale.result, fromCache: true, rateLimited: true };
481
+ }
482
+ return { error: "GitHub search rate limit exceeded — try again in a minute (30 requests/min quota)" };
483
+ }
484
+ if (ghResult.status !== 0) {
485
+ const msg = String(ghResult.stderr ?? "").slice(0, 500).trim() || `gh exited ${ghResult.status ?? "unknown"}`;
486
+ return { error: msg };
487
+ }
488
+ const raw = String(ghResult.stdout ?? "").trim();
489
+ if (!raw) {
490
+ return { error: "gh returned empty output" };
491
+ }
492
+ let parsed;
493
+ try {
494
+ parsed = JSON.parse(raw);
495
+ }
496
+ catch {
497
+ return { error: "gh returned unparsable output" };
498
+ }
499
+ const fetchedAt = Date.now();
500
+ const cachedAt = new Date(fetchedAt).toISOString();
501
+ // Prune stale entries now that we have fresh data to store. Pruning here (rather
502
+ // than at the start of the function) ensures stale entries survive to serve as
503
+ // rate-limit fallbacks when the network call fails with 403.
504
+ pruneSearchCache();
505
+ if (input.openPrs) {
506
+ // PR search response: `{ total_count, items: [{ repository: {...} }] }`
507
+ const wrapper = parsed;
508
+ const items = Array.isArray(wrapper.items) ? wrapper.items : [];
509
+ // De-duplicate by repository full_name — one row per repo.
510
+ const seen = new Set();
511
+ const repos = [];
512
+ for (const item of items) {
513
+ const prItem = item;
514
+ const repoObj = prItem.repository;
515
+ if (!repoObj || typeof repoObj !== "object")
516
+ continue;
517
+ const entry = toSearchEntry(repoObj);
518
+ if (!entry || seen.has(entry.full_name))
519
+ continue;
520
+ seen.add(entry.full_name);
521
+ repos.push(entry);
522
+ if (repos.length >= limit)
523
+ break;
524
+ }
525
+ const result = {
526
+ checked: true,
527
+ repos,
528
+ totalCount: typeof wrapper.total_count === "number" ? wrapper.total_count : repos.length,
529
+ fromCache: false,
530
+ cachedAt,
531
+ canonicalQuery,
532
+ };
533
+ SEARCH_CACHE.set(cacheKey, { result, fetchedAt });
534
+ return result;
535
+ }
536
+ // Repository search response: `{ total_count, items: [{…repo…}] }`.
537
+ const wrapper = parsed;
538
+ const items = Array.isArray(wrapper.items) ? wrapper.items : [];
539
+ const repos = [];
540
+ for (const item of items) {
541
+ const entry = toSearchEntry(item);
542
+ if (entry)
543
+ repos.push(entry);
544
+ }
545
+ const result = {
546
+ checked: true,
547
+ repos,
548
+ totalCount: typeof wrapper.total_count === "number" ? wrapper.total_count : repos.length,
549
+ fromCache: false,
550
+ cachedAt,
551
+ canonicalQuery,
552
+ };
553
+ SEARCH_CACHE.set(cacheKey, { result, fetchedAt });
554
+ return result;
555
+ }
556
+ /** Maximum items to return per list. Mirrors GitHub's own page cap for these endpoints. */
557
+ const DETAIL_MAX_LIMIT = 100;
558
+ const DETAIL_DEFAULT_LIMIT = 30;
559
+ /** How long a cached detail result is considered fresh (milliseconds). */
560
+ const DETAIL_TTL_MS = 2 * 60 * 1000; // 2 minutes — issues/PRs change faster than repo metadata
561
+ const DETAIL_CACHE = new Map();
562
+ /** Evict stale entries to keep the cache bounded. */
563
+ function pruneDetailCache() {
564
+ const cutoff = Date.now() - DETAIL_TTL_MS;
565
+ for (const [key, entry] of DETAIL_CACHE) {
566
+ if (entry.fetchedAt < cutoff)
567
+ DETAIL_CACHE.delete(key);
568
+ }
569
+ }
570
+ /**
571
+ * Clear the entire detail cache.
572
+ *
573
+ * Exported for test isolation only — tests must call this in `beforeEach` so
574
+ * that no cached result bleeds from one test case to the next.
575
+ */
576
+ export function clearDetailCacheForTesting() {
577
+ DETAIL_CACHE.clear();
578
+ }
579
+ /**
580
+ * Run a single `gh api` call (no `--paginate`) and return the parsed JSON, or
581
+ * `{ error }` on failure. Used for the detail endpoints where pagination would
582
+ * consume too much quota; instead `per_page` is bounded by `limit`.
583
+ *
584
+ * Returns the raw parsed value (object or array), not wrapped in `unknown[]`,
585
+ * because the detail API paths return both array and object shapes.
586
+ */
587
+ function runGhApiOnce(path) {
588
+ // Read-only by construction (#688): `path` is a REST resource path, never a method
589
+ // flag. The only argument to `gh api` is the path; the default method is GET and
590
+ // there is no way to override it from this call site. `assertReadOnlyGhApiArgs`
591
+ // covers `runGhApi` (where args is caller-supplied); here the signature enforces it.
592
+ const result = spawnSync("gh", ["api", path], {
593
+ encoding: "utf-8",
594
+ timeout: 30_000,
595
+ env: process.env,
596
+ });
597
+ if (result.error) {
598
+ const code = result.error?.code;
599
+ return { error: code === "ENOENT" ? "`gh` is not installed or not on PATH" : result.error.message };
600
+ }
601
+ if (result.status !== 0) {
602
+ const msg = String(result.stderr ?? "").slice(0, 500).trim() || `gh exited ${result.status ?? "unknown"}`;
603
+ return { error: msg };
604
+ }
605
+ const raw = String(result.stdout ?? "").trim();
606
+ if (!raw)
607
+ return { error: "gh returned empty output" };
608
+ try {
609
+ return JSON.parse(raw);
610
+ }
611
+ catch {
612
+ return { error: "gh returned unparsable output" };
613
+ }
614
+ }
615
+ /** Project a raw GitHub issue object to {@link GithubIssueEntry}. */
616
+ function toIssueEntry(raw) {
617
+ if (!raw || typeof raw !== "object")
618
+ return null;
619
+ const r = raw;
620
+ const num = typeof r.number === "number" ? r.number : Number(r.number);
621
+ if (!Number.isFinite(num) || num < 1)
622
+ return null;
623
+ const title = r.title != null ? String(r.title) : "";
624
+ const rawState = String(r.state ?? "open");
625
+ const state = rawState === "closed" ? "closed" : "open";
626
+ const author = r.user != null && typeof r.user === "object"
627
+ ? (String(r.user.login ?? "") || null)
628
+ : null;
629
+ const labels = Array.isArray(r.labels)
630
+ ? r.labels.map((l) => {
631
+ if (typeof l === "string")
632
+ return l;
633
+ if (l && typeof l === "object")
634
+ return String(l.name ?? "");
635
+ return "";
636
+ }).filter(Boolean)
637
+ : [];
638
+ const assignee = r.assignee != null && typeof r.assignee === "object"
639
+ ? (String(r.assignee.login ?? "") || null)
640
+ : null;
641
+ return {
642
+ number: num,
643
+ title,
644
+ state,
645
+ author,
646
+ created_at: r.created_at != null ? String(r.created_at) : "",
647
+ updated_at: r.updated_at != null ? String(r.updated_at) : "",
648
+ labels,
649
+ assignee,
650
+ };
651
+ }
652
+ /** Project a raw GitHub pull request object to {@link GithubPullEntry}. */
653
+ function toPullEntry(raw) {
654
+ if (!raw || typeof raw !== "object")
655
+ return null;
656
+ const r = raw;
657
+ const num = typeof r.number === "number" ? r.number : Number(r.number);
658
+ if (!Number.isFinite(num) || num < 1)
659
+ return null;
660
+ const title = r.title != null ? String(r.title) : "";
661
+ // GitHub marks merged PRs as `state: "closed"` with a `merged_at` timestamp.
662
+ const rawState = String(r.state ?? "open");
663
+ const merged = r.merged_at != null && r.merged_at !== null;
664
+ const state = merged ? "merged" : rawState === "closed" ? "closed" : "open";
665
+ const author = r.user != null && typeof r.user === "object"
666
+ ? (String(r.user.login ?? "") || null)
667
+ : null;
668
+ const head = r.head != null && typeof r.head === "object"
669
+ ? String(r.head.ref ?? "") : "";
670
+ const base = r.base != null && typeof r.base === "object"
671
+ ? String(r.base.ref ?? "") : "";
672
+ const labels = Array.isArray(r.labels)
673
+ ? r.labels.map((l) => {
674
+ if (typeof l === "string")
675
+ return l;
676
+ if (l && typeof l === "object")
677
+ return String(l.name ?? "");
678
+ return "";
679
+ }).filter(Boolean)
680
+ : [];
681
+ return {
682
+ number: num,
683
+ title,
684
+ state,
685
+ author,
686
+ created_at: r.created_at != null ? String(r.created_at) : "",
687
+ updated_at: r.updated_at != null ? String(r.updated_at) : "",
688
+ head_branch: head,
689
+ base_branch: base,
690
+ draft: r.draft === true,
691
+ labels,
692
+ };
693
+ }
694
+ /** Project a raw GitHub branch object to {@link GithubBranchEntry}. */
695
+ function toBranchEntry(raw) {
696
+ if (!raw || typeof raw !== "object")
697
+ return null;
698
+ const r = raw;
699
+ const name = r.name != null ? String(r.name) : "";
700
+ if (!name)
701
+ return null;
702
+ const commit = r.commit != null && typeof r.commit === "object"
703
+ ? r.commit
704
+ : {};
705
+ const sha = commit.sha != null ? String(commit.sha) : "";
706
+ return {
707
+ name,
708
+ sha,
709
+ protected: r.protected === true,
710
+ };
711
+ }
712
+ /**
713
+ * Fetch issues, pull requests, and branches for a given `owner/repo`.
714
+ *
715
+ * Read-only by construction: the only `gh` calls are `gh api GET /repos/{owner}/{repo}/issues`,
716
+ * `/pulls`, and `/branches` — all read endpoints. Never writes, never mutates anything.
717
+ *
718
+ * Results are cached in-process for {@link DETAIL_TTL_MS} (2 minutes) to guard against
719
+ * repeated calls from the same runner session. Cache hits return `fromCache: true`.
720
+ *
721
+ * Never throws — every failure is a descriptive `{ error }` property.
722
+ */
723
+ export function getGithubRepoDetail(input) {
724
+ const repoSlug = (input.repo ?? "").trim();
725
+ // Validate the slug — must be "owner/repo" with no extra slashes.
726
+ if (!repoSlug?.includes("/") || repoSlug.split("/").length !== 2) {
727
+ return { error: "repo must be an \"owner/repo\" slug (e.g. \"serge-ivo/my-app\")" };
728
+ }
729
+ const limit = Math.max(1, Math.min(DETAIL_MAX_LIMIT, Math.floor(Number(input.limit) || DETAIL_DEFAULT_LIMIT)));
730
+ const state = input.state === "all" ? "all" : "open";
731
+ const cacheKey = `${repoSlug}:${limit}:${state}`;
732
+ const existing = DETAIL_CACHE.get(cacheKey);
733
+ if (existing && Date.now() - existing.fetchedAt < DETAIL_TTL_MS) {
734
+ return { ...existing.result, fromCache: true };
735
+ }
736
+ const encodedRepo = repoSlug.split("/").map(encodeURIComponent).join("/");
737
+ // Fetch issues (GitHub issues endpoint returns issues only, not PRs, when `pulls=false`
738
+ // is implicit). Use `?state=open&per_page=N` to bound the result.
739
+ const issuesRaw = runGhApiOnce(`repos/${encodedRepo}/issues?state=${state}&per_page=${limit}&sort=updated&direction=desc`);
740
+ if (issuesRaw != null && typeof issuesRaw === "object" && "error" in issuesRaw) {
741
+ return issuesRaw;
742
+ }
743
+ // Fetch pull requests.
744
+ const pullsRaw = runGhApiOnce(`repos/${encodedRepo}/pulls?state=${state}&per_page=${limit}&sort=updated&direction=desc`);
745
+ if (pullsRaw != null && typeof pullsRaw === "object" && "error" in pullsRaw) {
746
+ return pullsRaw;
747
+ }
748
+ // Fetch branches (no state filter — branches are either there or not).
749
+ const branchesRaw = runGhApiOnce(`repos/${encodedRepo}/branches?per_page=${limit}`);
750
+ if (branchesRaw != null && typeof branchesRaw === "object" && "error" in branchesRaw) {
751
+ return branchesRaw;
752
+ }
753
+ // Project down to typed entries, skipping malformed items.
754
+ const issues = [];
755
+ if (Array.isArray(issuesRaw)) {
756
+ for (const item of issuesRaw) {
757
+ // GitHub's /issues endpoint also returns pull requests — exclude them so the
758
+ // caller gets a clean issues-only list (PRs are in the pulls array).
759
+ if (item && typeof item === "object" && item.pull_request != null)
760
+ continue;
761
+ const entry = toIssueEntry(item);
762
+ if (entry)
763
+ issues.push(entry);
764
+ }
765
+ }
766
+ const pulls = [];
767
+ if (Array.isArray(pullsRaw)) {
768
+ for (const item of pullsRaw) {
769
+ const entry = toPullEntry(item);
770
+ if (entry)
771
+ pulls.push(entry);
772
+ }
773
+ }
774
+ const branches = [];
775
+ if (Array.isArray(branchesRaw)) {
776
+ for (const item of branchesRaw) {
777
+ const entry = toBranchEntry(item);
778
+ if (entry)
779
+ branches.push(entry);
780
+ }
781
+ }
782
+ const fetchedAt = Date.now();
783
+ const cachedAt = new Date(fetchedAt).toISOString();
784
+ pruneDetailCache();
785
+ const result = {
786
+ checked: true,
787
+ repo: repoSlug,
788
+ issues,
789
+ pulls,
790
+ branches,
791
+ fromCache: false,
792
+ cachedAt,
793
+ };
794
+ DETAIL_CACHE.set(cacheKey, { result, fetchedAt });
795
+ return result;
796
+ }