@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.
- package/dist/browser-runner/coding/engine-acts.js +7 -7
- package/dist/browser-runner/coding/engine-adapter.js +186 -0
- package/dist/browser-runner/coding/engine-usage.js +27 -3
- package/dist/browser-runner/coding/github-browse.js +796 -0
- package/dist/browser-runner/coding/handlers.js +1 -1
- package/dist/browser-runner/coding/headless.js +121 -118
- package/dist/browser-runner/coding/inspect.js +188 -13
- package/dist/browser-runner/coding/repo.js +44 -1
- package/dist/browser-runner/coding/runtime.js +20 -3
- package/dist/browser-runner/server.js +103 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|