dsh-plugin-github-issues 0.0.0-stage → 0.5.0

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -2
  3. package/assets/panel.png +0 -0
  4. package/cordis.patch.yml +20 -0
  5. package/docs/README.zh.md +138 -0
  6. package/icon.svg +9 -0
  7. package/lib/client.js +3348 -0
  8. package/lib/controller.js +296 -0
  9. package/lib/controller.js.map +1 -0
  10. package/lib/github/auth.js +249 -0
  11. package/lib/github/auth.js.map +1 -0
  12. package/lib/github/client.js +797 -0
  13. package/lib/github/client.js.map +1 -0
  14. package/lib/github/queries.js +508 -0
  15. package/lib/github/queries.js.map +1 -0
  16. package/lib/github/state.js +190 -0
  17. package/lib/github/state.js.map +1 -0
  18. package/lib/github/types.js +29 -0
  19. package/lib/github/types.js.map +1 -0
  20. package/lib/github/vm.js +362 -0
  21. package/lib/github/vm.js.map +1 -0
  22. package/lib/index.js +65 -0
  23. package/lib/index.js.map +1 -0
  24. package/lib/prompt.js +87 -0
  25. package/lib/prompt.js.map +1 -0
  26. package/lib/remote-methods.js +33 -0
  27. package/lib/remote-methods.js.map +1 -0
  28. package/lib/types/controller.d.ts +109 -0
  29. package/lib/types/controller.d.ts.map +1 -0
  30. package/lib/types/github/auth.d.ts +52 -0
  31. package/lib/types/github/auth.d.ts.map +1 -0
  32. package/lib/types/github/client.d.ts +40 -0
  33. package/lib/types/github/client.d.ts.map +1 -0
  34. package/lib/types/github/queries.d.ts +181 -0
  35. package/lib/types/github/queries.d.ts.map +1 -0
  36. package/lib/types/github/state.d.ts +51 -0
  37. package/lib/types/github/state.d.ts.map +1 -0
  38. package/lib/types/github/types.d.ts +83 -0
  39. package/lib/types/github/types.d.ts.map +1 -0
  40. package/lib/types/github/vm.d.ts +260 -0
  41. package/lib/types/github/vm.d.ts.map +1 -0
  42. package/lib/types/index.d.ts +46 -0
  43. package/lib/types/index.d.ts.map +1 -0
  44. package/lib/types/prompt.d.ts +30 -0
  45. package/lib/types/prompt.d.ts.map +1 -0
  46. package/lib/types/remote-methods.d.ts +32 -0
  47. package/lib/types/remote-methods.d.ts.map +1 -0
  48. package/lib/types/vm.d.ts +268 -0
  49. package/lib/types/vm.d.ts.map +1 -0
  50. package/lib/types/workspace.d.ts +43 -0
  51. package/lib/types/workspace.d.ts.map +1 -0
  52. package/lib/vm.js +43 -0
  53. package/lib/vm.js.map +1 -0
  54. package/lib/workspace.js +57 -0
  55. package/lib/workspace.js.map +1 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +99 -4
@@ -0,0 +1,797 @@
1
+ /**
2
+ * The Host-side GitHub GraphQL client.
3
+ *
4
+ * This is the only module that talks to api.github.com (or a GHES equivalent).
5
+ * It owns four concerns that the rest of the plugin should not have to know
6
+ * about:
7
+ *
8
+ * - **The credential.** The token lives in this closure and appears only in an
9
+ * `Authorization` header; it is never logged and never placed in an error.
10
+ * - **Failure taxonomy.** GitHub answers GraphQL failures with HTTP 200 plus an
11
+ * `errors[]` array, and transport failures with 401/403/404/429; both are
12
+ * classified into a small set of codes so the UI can say what to fix.
13
+ * - **Scoping quirks.** Projects v2 need `read:project`, which a machine's `gh`
14
+ * token may not have. That is a blocked picker, not a broken plugin, so it
15
+ * degrades to `projectsBlocked` and a notice.
16
+ * - **Caching.** The panel polls, so responses are kept briefly in memory; a
17
+ * refresh drops them. GraphQL has no conditional requests to rely on.
18
+ *
19
+ * The interface itself is defined in `types.ts`; this module only implements it.
20
+ *
21
+ * @module dsh-plugin-github-issues/github/client
22
+ */
23
+ import { buildIssueDetailQuery, buildMentionedPRsQuery, buildOrgProjectsQuery, buildOrgReposQuery, buildProjectItemsQuery, buildRepoIssuesQuery, buildRepoMilestonesQuery, buildScopeQuery, buildSearchIssuesQuery, projectItemsFilterFor, searchStringForFilters, statesForView, } from "./queries.js";
24
+ import { GitHubApiError, splitRepo } from "./types.js";
25
+ import { toIssueCardVM, toIssueDetailVM, toMentionedPRsVM, toProjectItemCardVM } from "./vm.js";
26
+ /** The `User-Agent` GitHub asks every API client to send. */
27
+ const USER_AGENT = 'dsh-plugin-github-issues';
28
+ /** One request's wall-clock budget. */
29
+ const REQUEST_TIMEOUT_MS = 20_000;
30
+ /** How long the scope catalog is reused; the picker need not be real-time. */
31
+ const SCOPES_TTL_MS = 5 * 60 * 1_000;
32
+ /** How long one list page is reused; the panel polls explicitly. */
33
+ const LIST_TTL_MS = 60 * 1_000;
34
+ /** How long one issue detail is reused. */
35
+ const DETAIL_TTL_MS = 30 * 1_000;
36
+ /** How long a repository's milestone titles stay cached. */
37
+ const MILESTONES_TTL_MS = 5 * 60 * 1_000;
38
+ /** The list page size used when a caller does not supply one. */
39
+ const DEFAULT_PAGE_SIZE = 30;
40
+ /** Repositories are fetched from at most two pages, so the picker stays cheap. */
41
+ const MAX_SCOPE_REPOS = 200;
42
+ /** Organizations queried for projects; each alias costs points. */
43
+ const PROJECT_SCOPE_LIMIT = 25;
44
+ /**
45
+ * The GraphQL endpoint for one host.
46
+ * github.com uses the dedicated API host; every GitHub Enterprise Server host
47
+ * serves GraphQL under its own `/api/graphql` path.
48
+ * @param host - configured host, e.g. `github.com` or `ghe.example.com`.
49
+ * @returns the absolute GraphQL endpoint.
50
+ */
51
+ export function endpointFor(host) {
52
+ const trimmed = host.trim().replace(/\/+$/, '');
53
+ if (trimmed === '' || trimmed === 'github.com' || trimmed === 'api.github.com') {
54
+ return 'https://api.github.com/graphql';
55
+ }
56
+ if (/^https?:\/\//i.test(trimmed))
57
+ return `${trimmed}/api/graphql`;
58
+ return `https://${trimmed}/api/graphql`;
59
+ }
60
+ /** Read the global `fetch` at call time, so a test can substitute it. */
61
+ function globalFetch() {
62
+ const candidate = globalThis.fetch;
63
+ return typeof candidate === 'function' ? candidate : undefined;
64
+ }
65
+ /** Build an abort error whose name `AbortError` matches fetch's convention. */
66
+ function abortReason(signal) {
67
+ const reason = signal?.reason;
68
+ if (reason instanceof Error)
69
+ return reason;
70
+ const error = new Error('The operation was aborted');
71
+ error.name = 'AbortError';
72
+ return error;
73
+ }
74
+ /** Turn an unknown thrown value into a message that is safe to display. */
75
+ function messageOf(error) {
76
+ return error instanceof Error ? error.message : String(error);
77
+ }
78
+ /** Parse a body, returning `undefined` rather than throwing on malformed JSON. */
79
+ function parseJson(text) {
80
+ try {
81
+ return JSON.parse(text);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ /** Keep the fields of `errors[]` the classifier reads. */
88
+ function rawErrorsOf(value) {
89
+ if (!Array.isArray(value))
90
+ return [];
91
+ const errors = [];
92
+ for (const entry of value) {
93
+ if (typeof entry !== 'object' || entry === null)
94
+ continue;
95
+ const record = entry;
96
+ errors.push({
97
+ ...typeof record.type === 'string' ? { type: record.type } : {},
98
+ ...typeof record.message === 'string' ? { message: record.message } : {},
99
+ });
100
+ }
101
+ return errors;
102
+ }
103
+ /** The first non-empty error message, preserving GitHub's wording. */
104
+ function firstMessage(errors) {
105
+ for (const error of errors) {
106
+ if (typeof error.message === 'string' && error.message !== '')
107
+ return error.message;
108
+ }
109
+ return undefined;
110
+ }
111
+ /** Whether an error means the token lacks a required scope. */
112
+ function isScopeError(error) {
113
+ return error.type === 'INSUFFICIENT_SCOPES'
114
+ || /INSUFFICIENT_SCOPES|required scopes|not been granted the required scopes/i.test(error.message ?? '');
115
+ }
116
+ /** Whether an error means a primary or secondary rate limit was hit. */
117
+ function isRateLimitError(error) {
118
+ return error.type === 'RATE_LIMITED'
119
+ || /rate limit|secondary rate|abuse detection/i.test(error.message ?? '');
120
+ }
121
+ /** Whether an error means the addressed object does not exist or is inaccessible. */
122
+ function isNotFoundError(error) {
123
+ return error.type === 'NOT_FOUND';
124
+ }
125
+ /** Whether an error means the request was not authorized. */
126
+ function isUnauthorizedError(error) {
127
+ return error.type === 'UNAUTHORIZED';
128
+ }
129
+ /**
130
+ * Classify an HTTP failure that arrived without a usable GraphQL body.
131
+ * Rate limits are checked first because GitHub reports them as a plain 403 with
132
+ * a `Retry-After` header, which would otherwise look like a permission problem.
133
+ */
134
+ function httpFailure(status, headers, text, errors, topLevelMessage) {
135
+ const retryAfter = headers.get('retry-after');
136
+ const remaining = headers.get('x-ratelimit-remaining');
137
+ const message = firstMessage(errors) ?? topLevelMessage;
138
+ if (status === 429 || retryAfter !== null || remaining === '0' || /rate limit|secondary rate/i.test(text)) {
139
+ return new GitHubApiError(message ?? `GitHub rate limit exceeded${retryAfter === null ? '.' : `; retry after ${retryAfter} s.`}`, 'rate_limited', status);
140
+ }
141
+ if (errors.some(isScopeError) || /required scopes|not been granted|INSUFFICIENT_SCOPES/i.test(text)) {
142
+ return new GitHubApiError(message ?? 'The GitHub token is missing a required scope.', 'missing_scope', status);
143
+ }
144
+ return new GitHubApiError(message ?? `GitHub request failed with HTTP ${status}.`, 'graphql', status);
145
+ }
146
+ /** Classify the `errors[]` of a 2xx GraphQL response. */
147
+ function errorFromBody(errors, status) {
148
+ if (errors.length === 0)
149
+ return undefined;
150
+ const message = firstMessage(errors) ?? 'GitHub returned an error for the request.';
151
+ if (errors.some(isScopeError)) {
152
+ return new GitHubApiError(message, 'missing_scope', status >= 400 ? status : 403);
153
+ }
154
+ if (errors.some(isRateLimitError)) {
155
+ return new GitHubApiError(message, 'rate_limited', status >= 400 ? status : 429);
156
+ }
157
+ if (errors.some(isUnauthorizedError))
158
+ return new GitHubApiError(message, 'unauthorized', 401);
159
+ if (errors.some(isNotFoundError)) {
160
+ return new GitHubApiError(message, 'not_found', status >= 400 ? status : 404);
161
+ }
162
+ return new GitHubApiError(message, 'graphql', status >= 400 ? status : undefined);
163
+ }
164
+ /** Normalize the pagination block into the view model's shape. */
165
+ function pageInfoOf(raw) {
166
+ return {
167
+ hasNextPage: raw?.hasNextPage === true,
168
+ endCursor: typeof raw?.endCursor === 'string' ? raw.endCursor : null,
169
+ };
170
+ }
171
+ /** Map the `rateLimit` selection, or `undefined` when it was not requested. */
172
+ function rateLimitOf(raw) {
173
+ if (raw === null || raw === undefined)
174
+ return undefined;
175
+ const cost = typeof raw.cost === 'number' ? raw.cost : undefined;
176
+ const remaining = typeof raw.remaining === 'number' ? raw.remaining : undefined;
177
+ if (cost === undefined && remaining === undefined)
178
+ return undefined;
179
+ return {
180
+ ...cost === undefined ? {} : { cost },
181
+ ...remaining === undefined ? {} : { remaining },
182
+ };
183
+ }
184
+ /** A page that needs no round trip: empty, not paged, optionally with a reason. */
185
+ function emptyPage(notice) {
186
+ return {
187
+ items: [],
188
+ pageInfo: { hasNextPage: false, endCursor: null },
189
+ ...notice === undefined ? {} : { notice },
190
+ };
191
+ }
192
+ /** The message shown wherever Projects v2 could not be read for lack of scope. */
193
+ function projectsScopeMessage(host) {
194
+ return `Projects v2 need the "read:project" scope. Run: gh auth refresh -h ${host} -s read:project`;
195
+ }
196
+ /** Normalize and validate a list request, clamping paging values. */
197
+ function normalizeListRequest(request) {
198
+ const repos = [...new Set((request.repos ?? []).filter(repo => typeof repo === 'string' && repo.includes('/')))].sort();
199
+ const first = typeof request.first === 'number' && Number.isFinite(request.first)
200
+ ? Math.min(Math.max(Math.trunc(request.first), 1), 50)
201
+ : DEFAULT_PAGE_SIZE;
202
+ const after = typeof request.after === 'string' && request.after !== '' ? request.after : null;
203
+ const milestone = typeof request.milestone === 'string' && request.milestone.trim() !== ''
204
+ ? request.milestone.trim().slice(0, 200)
205
+ : undefined;
206
+ const state = request.state === 'closed' || request.state === 'all' ? request.state : 'open';
207
+ const projectId = typeof request.projectId === 'string' && request.projectId !== '' ? request.projectId : undefined;
208
+ const org = typeof request.org === 'string' && request.org !== '' ? request.org : undefined;
209
+ const view = request.view;
210
+ const assignee = typeof request.assignee === 'string' && request.assignee !== ''
211
+ ? request.assignee
212
+ : view === 'assigned' ? '@me' : 'all';
213
+ const author = typeof request.author === 'string' && request.author !== ''
214
+ ? request.author
215
+ : view === 'created' ? '@me' : undefined;
216
+ return {
217
+ ...view === undefined ? {} : { view },
218
+ repos,
219
+ state,
220
+ assignee,
221
+ first,
222
+ after,
223
+ ...author === undefined ? {} : { author },
224
+ ...projectId === undefined ? {} : { projectId },
225
+ ...org === undefined ? {} : { org },
226
+ ...milestone === undefined ? {} : { milestone },
227
+ };
228
+ }
229
+ /** The cache key for a normalized request; its property order is fixed. */
230
+ function listCacheKey(request) {
231
+ return JSON.stringify(request);
232
+ }
233
+ /** Map a raw issue connection to cards, dropping entries the search returned without fields. */
234
+ function toCards(nodes) {
235
+ const cards = [];
236
+ for (const node of nodes ?? []) {
237
+ if (node === null)
238
+ continue;
239
+ if (typeof node.number !== 'number' || typeof node.id !== 'string' || node.id === '')
240
+ continue;
241
+ cards.push(toIssueCardVM(node));
242
+ }
243
+ return cards;
244
+ }
245
+ /** Whether a card survives the project view's client-side state filter. */
246
+ function stateMatches(state, filter) {
247
+ if (filter === 'all')
248
+ return true;
249
+ return filter === 'open' ? state === 'OPEN' : state === 'CLOSED';
250
+ }
251
+ /** Map the viewer block of the scope catalog. */
252
+ function viewerOf(raw) {
253
+ if (raw === null || raw === undefined || typeof raw.login !== 'string' || raw.login === '')
254
+ return null;
255
+ return {
256
+ login: raw.login,
257
+ ...typeof raw.avatarUrl === 'string' && raw.avatarUrl !== '' ? { avatarUrl: raw.avatarUrl } : {},
258
+ };
259
+ }
260
+ /** Map and dedupe the organizations, keeping a display name when GitHub sends one. */
261
+ function orgsOf(raw) {
262
+ const orgs = [];
263
+ const seen = new Set();
264
+ for (const node of raw?.nodes ?? []) {
265
+ if (node === null || typeof node.login !== 'string' || node.login === '')
266
+ continue;
267
+ if (seen.has(node.login))
268
+ continue;
269
+ seen.add(node.login);
270
+ orgs.push({
271
+ login: node.login,
272
+ ...typeof node.name === 'string' && node.name !== '' ? { name: node.name } : {},
273
+ });
274
+ }
275
+ return orgs;
276
+ }
277
+ /** Merge organization lists by login, keeping the display name when one side has it. */
278
+ function mergeOrgs(...lists) {
279
+ const merged = new Map();
280
+ for (const list of lists) {
281
+ for (const org of list) {
282
+ const existing = merged.get(org.login);
283
+ const name = org.name ?? existing?.name;
284
+ merged.set(org.login, name === undefined ? { login: org.login } : { login: org.login, name });
285
+ }
286
+ }
287
+ return [...merged.values()].sort((a, b) => a.login.localeCompare(b.login));
288
+ }
289
+ /** Append one repository page, deduping by `nameWithOwner`. */
290
+ function appendRepos(target, page) {
291
+ const seen = new Set(target.map(repo => repo.nameWithOwner));
292
+ for (const node of page?.nodes ?? []) {
293
+ if (node === null || typeof node.nameWithOwner !== 'string' || node.nameWithOwner === '')
294
+ continue;
295
+ if (seen.has(node.nameWithOwner))
296
+ continue;
297
+ seen.add(node.nameWithOwner);
298
+ target.push({
299
+ nameWithOwner: node.nameWithOwner,
300
+ isPrivate: node.isPrivate === true,
301
+ isFork: node.isFork === true,
302
+ ...typeof node.pushedAt === 'string' && node.pushedAt !== '' ? { pushedAt: node.pushedAt } : {},
303
+ });
304
+ }
305
+ }
306
+ /** Append one project connection, deduping by node id. */
307
+ function appendProjects(target, connection, owner) {
308
+ if (owner === undefined)
309
+ return;
310
+ const seen = new Set(target.map(project => project.id));
311
+ for (const node of connection?.nodes ?? []) {
312
+ if (node === null)
313
+ continue;
314
+ if (typeof node.id !== 'string' || node.id === '')
315
+ continue;
316
+ if (typeof node.number !== 'number' || typeof node.title !== 'string' || typeof node.url !== 'string')
317
+ continue;
318
+ if (seen.has(node.id))
319
+ continue;
320
+ seen.add(node.id);
321
+ target.push({ id: node.id, number: node.number, title: node.title, url: node.url, owner });
322
+ }
323
+ }
324
+ /** The GitHub client behind the plugin's contract. */
325
+ class GitHubApiClient {
326
+ host;
327
+ token;
328
+ endpoint;
329
+ baseSignal;
330
+ scopesCache;
331
+ projectsBlocked = false;
332
+ projectsError;
333
+ listCache = new Map();
334
+ detailCache = new Map();
335
+ mentionedCache = new Map();
336
+ milestonesCache = new Map();
337
+ orgReposCache = new Map();
338
+ orgProjectsCache = new Map();
339
+ /**
340
+ * @param options - token, host, and an optional signal covering the session.
341
+ */
342
+ constructor(options) {
343
+ this.host = options.host;
344
+ this.token = options.token;
345
+ this.endpoint = endpointFor(options.host);
346
+ this.baseSignal = options.signal;
347
+ }
348
+ /** The viewer's organizations and repositories, for the cascade's first level. */
349
+ async scopes(signal) {
350
+ const cached = this.scopesCache;
351
+ if (cached !== undefined && Date.now() - cached.at < SCOPES_TTL_MS)
352
+ return cached.data;
353
+ const catalog = await this.fetchScopes(signal);
354
+ this.scopesCache = { at: Date.now(), data: catalog };
355
+ return catalog;
356
+ }
357
+ /** Projects v2 of one organization, loaded when the organization is chosen. */
358
+ async orgProjects(org, signal) {
359
+ const cached = this.orgProjectsCache.get(org);
360
+ if (cached !== undefined && Date.now() - cached.at < SCOPES_TTL_MS)
361
+ return cached.data;
362
+ const scopeMessage = projectsScopeMessage(this.host);
363
+ if (this.projectsBlocked) {
364
+ return { projects: [], blocked: true, error: this.projectsError ?? scopeMessage };
365
+ }
366
+ const body = await this.request(buildOrgProjectsQuery(org), signal);
367
+ if (body.errors.some(isScopeError)) {
368
+ this.projectsBlocked = true;
369
+ this.projectsError = scopeMessage;
370
+ return { projects: [], blocked: true, error: scopeMessage };
371
+ }
372
+ if (body.errors.some(isRateLimitError))
373
+ throw errorFromBody(body.errors, body.status);
374
+ const data = (body.data ?? undefined);
375
+ const projects = [];
376
+ appendProjects(projects, data?.organization?.projectsV2, org);
377
+ const failure = body.errors.find(error => !isNotFoundError(error));
378
+ const result = {
379
+ projects,
380
+ blocked: false,
381
+ ...failure === undefined ? {} : { error: `Projects could not be read: ${firstMessage([failure]) ?? 'unknown error'}` },
382
+ };
383
+ this.orgProjectsCache.set(org, { at: Date.now(), data: result });
384
+ return result;
385
+ }
386
+ /**
387
+ * One organization's repositories, forks included.
388
+ * `organization.repositories` has no search argument, so up to four pages are
389
+ * fetched and the browser filters the list by name.
390
+ */
391
+ async orgRepos(org, signal) {
392
+ const cached = this.orgReposCache.get(org);
393
+ if (cached !== undefined && Date.now() - cached.at < SCOPES_TTL_MS)
394
+ return cached.data;
395
+ const items = [];
396
+ let totalCount = 0;
397
+ let after = null;
398
+ let hasNextPage = false;
399
+ for (let page = 0; page < 4; page += 1) {
400
+ const data = this.unwrap(await this.request(buildOrgReposQuery({ org, first: 100, after }), signal));
401
+ const connection = data.organization?.repositories;
402
+ appendRepos(items, connection);
403
+ if (typeof connection?.totalCount === 'number')
404
+ totalCount = connection.totalCount;
405
+ const pageInfo = connection?.pageInfo;
406
+ hasNextPage = pageInfo?.hasNextPage === true;
407
+ const cursor = pageInfo?.endCursor;
408
+ after = typeof cursor === 'string' && cursor !== '' ? cursor : null;
409
+ if (!hasNextPage || after === null)
410
+ break;
411
+ }
412
+ const result = { items, totalCount, hasNextPage, endCursor: after };
413
+ this.orgReposCache.set(org, { at: Date.now(), data: result });
414
+ return result;
415
+ }
416
+ /** One page of issues for the requested view. */
417
+ async listIssues(request, signal) {
418
+ const normalized = normalizeListRequest(request);
419
+ const key = listCacheKey(normalized);
420
+ const cached = this.listCache.get(key);
421
+ if (cached !== undefined && Date.now() - cached.at < LIST_TTL_MS)
422
+ return cached.data;
423
+ const page = await this.fetchList(normalized, signal);
424
+ this.listCache.set(key, { at: Date.now(), data: page });
425
+ return page;
426
+ }
427
+ /** One issue's body; the card data comes from the list the caller already has. */
428
+ async issueDetail(repo, number, signal) {
429
+ const { owner, name } = splitRepo(repo);
430
+ if (!Number.isInteger(number) || number <= 0) {
431
+ throw new GitHubApiError(`invalid issue number "${String(number)}"`, 'invalid_issue');
432
+ }
433
+ const key = `${repo}#${String(number)}`;
434
+ const cached = this.detailCache.get(key);
435
+ if (cached !== undefined && Date.now() - cached.at < DETAIL_TTL_MS)
436
+ return cached.data;
437
+ const data = this.unwrap(await this.request(buildIssueDetailQuery({ owner, name, number }), signal));
438
+ const issue = data.repository?.issue;
439
+ if (issue === null || issue === undefined) {
440
+ throw new GitHubApiError(`issue ${key} was not found or is not accessible`, 'not_found', 404);
441
+ }
442
+ // The cross-reference timeline is fetched separately and merged by the
443
+ // caller, so the detail pane is not held hostage by the slowest leg.
444
+ const detail = { body: typeof issue.body === 'string' ? issue.body : '' };
445
+ this.detailCache.set(key, { at: Date.now(), data: detail });
446
+ return detail;
447
+ }
448
+ /** The issue's cross-referenced pull requests (lazy second leg of the detail). */
449
+ async issueMentionedPRs(repo, number, signal) {
450
+ const { owner, name } = splitRepo(repo);
451
+ if (!Number.isInteger(number) || number <= 0) {
452
+ throw new GitHubApiError(`invalid issue number "${String(number)}"`, 'invalid_issue');
453
+ }
454
+ const key = `${repo}#${String(number)}`;
455
+ const cached = this.mentionedCache.get(key);
456
+ if (cached !== undefined && Date.now() - cached.at < DETAIL_TTL_MS)
457
+ return cached.data;
458
+ const data = this.unwrap(await this.request(buildMentionedPRsQuery({ owner, name, number }), signal));
459
+ const result = { mentionedPRs: toMentionedPRsVM(data.repository?.issue?.timelineItems?.nodes) };
460
+ this.mentionedCache.set(key, { at: Date.now(), data: result });
461
+ return result;
462
+ }
463
+ /** A repository's milestone titles, for the milestone picker. */
464
+ async repoMilestones(repo, signal) {
465
+ const { owner, name } = splitRepo(repo);
466
+ const cached = this.milestonesCache.get(repo);
467
+ if (cached !== undefined && Date.now() - cached.at < MILESTONES_TTL_MS)
468
+ return cached.data;
469
+ const data = this.unwrap(await this.request(buildRepoMilestonesQuery(owner, name), signal));
470
+ const seen = new Set();
471
+ const milestones = [];
472
+ for (const node of data.repository?.milestones?.nodes ?? []) {
473
+ const title = typeof node?.title === 'string' ? node.title : '';
474
+ if (title === '' || seen.has(title))
475
+ continue;
476
+ seen.add(title);
477
+ milestones.push(title);
478
+ }
479
+ const result = { milestones };
480
+ this.milestonesCache.set(repo, { at: Date.now(), data: result });
481
+ return result;
482
+ }
483
+ /** Drop cached responses; `all` (or no argument) also drops the scope catalog. */
484
+ invalidate(scope) {
485
+ if (scope === 'list') {
486
+ this.listCache.clear();
487
+ return;
488
+ }
489
+ if (scope === 'detail') {
490
+ this.detailCache.clear();
491
+ this.mentionedCache.clear();
492
+ this.milestonesCache.clear();
493
+ return;
494
+ }
495
+ this.scopesCache = undefined;
496
+ this.orgReposCache.clear();
497
+ this.orgProjectsCache.clear();
498
+ this.projectsBlocked = false;
499
+ this.projectsError = undefined;
500
+ this.listCache.clear();
501
+ this.detailCache.clear();
502
+ this.mentionedCache.clear();
503
+ this.milestonesCache.clear();
504
+ }
505
+ /** Build the catalog: viewer, organizations (GraphQL + REST union), and repositories. */
506
+ async fetchScopes(signal) {
507
+ const core = await this.fetchScopeCore(signal);
508
+ return {
509
+ viewer: core.viewer,
510
+ orgs: core.orgs,
511
+ repos: core.repos,
512
+ projectsBlocked: this.projectsBlocked,
513
+ ...this.projectsError === undefined ? {} : { projectsError: this.projectsError },
514
+ };
515
+ }
516
+ /** The viewer, organizations, and up to two pages of repositories. */
517
+ async fetchScopeCore(signal) {
518
+ let data = this.unwrap(await this.request(buildScopeQuery(null), signal));
519
+ const viewer = viewerOf(data.viewer);
520
+ const graphOrgs = orgsOf(data.viewer?.organizations);
521
+ const repos = [];
522
+ appendRepos(repos, data.viewer?.repositories);
523
+ while (repos.length < MAX_SCOPE_REPOS) {
524
+ const pageInfo = data.viewer?.repositories?.pageInfo;
525
+ if (pageInfo?.hasNextPage !== true)
526
+ break;
527
+ const after = pageInfo.endCursor;
528
+ if (typeof after !== 'string' || after === '')
529
+ break;
530
+ data = this.unwrap(await this.request(buildScopeQuery(after), signal));
531
+ appendRepos(repos, data.viewer?.repositories);
532
+ }
533
+ // `viewer.organizations` reports public memberships only; the REST endpoint is
534
+ // the one that includes private ones. A failure is not an error: a union of
535
+ // whatever each source returned is still a better picker.
536
+ const restOrgs = await this.restOrgs(signal);
537
+ return { viewer, orgs: mergeOrgs(graphOrgs, restOrgs), repos };
538
+ }
539
+ /** Organizations as `/user/orgs` reports them (includes private memberships). */
540
+ async restOrgs(signal) {
541
+ try {
542
+ const base = this.host === 'github.com' ? 'https://api.github.com' : `https://${this.host}/api/v3`;
543
+ const effectiveSignal = signal ?? this.baseSignal;
544
+ const response = await fetch(`${base}/user/orgs?per_page=100`, {
545
+ headers: {
546
+ authorization: `Bearer ${this.token}`,
547
+ accept: 'application/vnd.github+json',
548
+ 'user-agent': USER_AGENT,
549
+ },
550
+ ...effectiveSignal === undefined ? {} : { signal: effectiveSignal },
551
+ });
552
+ if (!response.ok)
553
+ return [];
554
+ const rows = await response.json();
555
+ return rows
556
+ .filter((row) => typeof row.login === 'string' && row.login !== '')
557
+ .map(row => ({
558
+ login: row.login,
559
+ ...typeof row.name === 'string' && row.name !== '' ? { name: row.name } : {},
560
+ }));
561
+ }
562
+ catch {
563
+ return [];
564
+ }
565
+ }
566
+ /** Dispatch one request to the project, exact-repository, or search endpoint. */
567
+ async fetchList(request, signal) {
568
+ if (request.projectId !== undefined)
569
+ return await this.fetchProjectList(request, signal);
570
+ // A legacy repository request with no repository chosen answers with a hint
571
+ // instead of listing the whole account.
572
+ if (request.view === 'repo' && request.repos.length === 0) {
573
+ return emptyPage('Select a repository to read its issues.');
574
+ }
575
+ // A legacy single-repository request keeps the exact `issues` connection (and
576
+ // its authoritative totalCount); every cascade request is one search.
577
+ if (request.view === 'repo' && request.repos.length === 1 && request.assignee === 'all') {
578
+ return await this.fetchRepoList(request, signal);
579
+ }
580
+ return await this.fetchSearchList(request, signal);
581
+ }
582
+ /** A page from GitHub's search API, scoped by the cascade's qualifiers. */
583
+ async fetchSearchList(request, signal) {
584
+ const search = searchStringForFilters(request);
585
+ const data = this.unwrap(await this.request(buildSearchIssuesQuery({ search, first: request.first, after: request.after }), signal));
586
+ const connection = data.search;
587
+ const rateLimit = rateLimitOf(data.rateLimit);
588
+ return {
589
+ items: toCards(connection?.nodes),
590
+ pageInfo: pageInfoOf(connection?.pageInfo),
591
+ ...typeof connection?.issueCount === 'number' ? { totalCount: connection.issueCount } : {},
592
+ ...rateLimit === undefined ? {} : { rateLimit },
593
+ };
594
+ }
595
+ /** A page from one repository's exact `issues` connection. */
596
+ async fetchRepoList(request, signal) {
597
+ const [repo] = request.repos;
598
+ if (repo === undefined)
599
+ return emptyPage('Select a repository to read its issues.');
600
+ const { owner, name } = splitRepo(repo);
601
+ const data = this.unwrap(await this.request(buildRepoIssuesQuery({
602
+ owner,
603
+ name,
604
+ states: statesForView(request.state),
605
+ first: request.first,
606
+ after: request.after,
607
+ ...request.milestone === undefined ? {} : { milestone: request.milestone },
608
+ }), signal));
609
+ const issues = data.repository?.issues;
610
+ if (issues === null || issues === undefined) {
611
+ throw new GitHubApiError(`repository ${repo} was not found or is not accessible`, 'not_found', 404);
612
+ }
613
+ const rateLimit = rateLimitOf(data.rateLimit);
614
+ return {
615
+ items: toCards(issues.nodes),
616
+ pageInfo: pageInfoOf(issues.pageInfo),
617
+ ...typeof issues.totalCount === 'number' ? { totalCount: issues.totalCount } : {},
618
+ ...rateLimit === undefined ? {} : { rateLimit },
619
+ };
620
+ }
621
+ /**
622
+ * A page from one Projects v2 board.
623
+ *
624
+ * The state and assignee filters go to GitHub's own item filter (a 126-item
625
+ * board returns its 25 open items instead of the first page of 30 filtered
626
+ * locally). A repository filter has no server-side equivalent in the project
627
+ * filter DSL, so matching pages are scanned until the page is full.
628
+ */
629
+ async fetchProjectList(request, signal) {
630
+ if (this.projectsBlocked)
631
+ return emptyPage(this.projectsError ?? projectsScopeMessage(this.host));
632
+ if (request.projectId === undefined)
633
+ return emptyPage('Select a project to read its issues.');
634
+ const filter = projectItemsFilterFor(request);
635
+ const wantedRepos = new Set(request.repos);
636
+ const cards = [];
637
+ let cursor = request.after;
638
+ let totalCount;
639
+ let rateLimit;
640
+ let pageInfo;
641
+ let scanned = 0;
642
+ for (;;) {
643
+ let body;
644
+ try {
645
+ body = await this.request(buildProjectItemsQuery({
646
+ projectId: request.projectId,
647
+ first: request.first,
648
+ after: cursor,
649
+ filter,
650
+ }), signal);
651
+ }
652
+ catch (error) {
653
+ if (error instanceof GitHubApiError && error.code === 'missing_scope') {
654
+ this.projectsBlocked = true;
655
+ this.projectsError = projectsScopeMessage(this.host);
656
+ return emptyPage(this.projectsError);
657
+ }
658
+ throw error;
659
+ }
660
+ if (body.errors.some(isScopeError)) {
661
+ this.projectsBlocked = true;
662
+ this.projectsError = projectsScopeMessage(this.host);
663
+ return emptyPage(this.projectsError);
664
+ }
665
+ const data = this.unwrap(body);
666
+ const items = data.node?.items;
667
+ if (items === null || items === undefined) {
668
+ throw new GitHubApiError(`project ${request.projectId} was not found or is not accessible`, 'not_found', 404);
669
+ }
670
+ totalCount ??= typeof items.totalCount === 'number' ? items.totalCount : undefined;
671
+ rateLimit ??= rateLimitOf(data.rateLimit);
672
+ for (const item of items.nodes ?? []) {
673
+ const card = toProjectItemCardVM(item);
674
+ if (card === undefined)
675
+ continue;
676
+ if (wantedRepos.size > 0 && !wantedRepos.has(card.repo))
677
+ continue;
678
+ cards.push(card);
679
+ if (cards.length >= request.first)
680
+ break;
681
+ }
682
+ pageInfo = items.pageInfo;
683
+ scanned += 1;
684
+ const next = pageInfo?.hasNextPage === true ? pageInfo.endCursor : null;
685
+ if (cards.length >= request.first || scanned >= 4 || typeof next !== 'string' || next === '')
686
+ break;
687
+ cursor = next;
688
+ }
689
+ return {
690
+ items: cards,
691
+ ...totalCount === undefined ? {} : { totalCount },
692
+ ...rateLimit === undefined ? {} : { rateLimit },
693
+ pageInfo: {
694
+ hasNextPage: pageInfo?.hasNextPage === true,
695
+ endCursor: pageInfo?.endCursor ?? null,
696
+ },
697
+ };
698
+ }
699
+ /** POST one GraphQL operation and read the response, classifying transport failures. */
700
+ async request(request, signal) {
701
+ const fetchImpl = globalFetch();
702
+ if (fetchImpl === undefined) {
703
+ throw new GitHubApiError('This runtime has no fetch implementation; Node.js 18 or newer is required.', 'network');
704
+ }
705
+ const controller = new AbortController();
706
+ const external = [this.baseSignal, signal]
707
+ .filter((entry) => entry !== undefined);
708
+ let timedOut = false;
709
+ const timer = setTimeout(() => {
710
+ timedOut = true;
711
+ controller.abort();
712
+ }, REQUEST_TIMEOUT_MS);
713
+ const forwardAbort = () => controller.abort();
714
+ for (const entry of external) {
715
+ if (entry.aborted)
716
+ controller.abort();
717
+ else
718
+ entry.addEventListener('abort', forwardAbort, { once: true });
719
+ }
720
+ try {
721
+ let response;
722
+ let text;
723
+ try {
724
+ response = await fetchImpl(this.endpoint, {
725
+ method: 'POST',
726
+ headers: {
727
+ Authorization: `Bearer ${this.token}`,
728
+ Accept: 'application/json',
729
+ 'Content-Type': 'application/json',
730
+ 'User-Agent': USER_AGENT,
731
+ },
732
+ body: JSON.stringify(request),
733
+ signal: controller.signal,
734
+ });
735
+ text = await response.text();
736
+ }
737
+ catch (error) {
738
+ if (timedOut) {
739
+ throw new GitHubApiError(`The GitHub request timed out after ${String(REQUEST_TIMEOUT_MS / 1_000)} s.`, 'network');
740
+ }
741
+ const aborted = external.find(entry => entry.aborted);
742
+ if (aborted !== undefined)
743
+ throw abortReason(aborted);
744
+ throw new GitHubApiError(`Could not reach GitHub: ${messageOf(error)}`, 'network');
745
+ }
746
+ return this.interpret(response.status, response.headers, text);
747
+ }
748
+ finally {
749
+ clearTimeout(timer);
750
+ for (const entry of external)
751
+ entry.removeEventListener('abort', forwardAbort);
752
+ }
753
+ }
754
+ /** Turn one HTTP answer into a GraphQL result or a classified error. */
755
+ interpret(status, headers, text) {
756
+ const parsed = text.trim() === '' ? undefined : parseJson(text);
757
+ const record = typeof parsed === 'object' && parsed !== null ? parsed : undefined;
758
+ const errors = rawErrorsOf(record?.errors);
759
+ const topLevelMessage = typeof record?.message === 'string' && record.message !== '' ? record.message : undefined;
760
+ if (status === 401) {
761
+ throw new GitHubApiError('GitHub rejected the token (HTTP 401). The gh token may have expired; run: gh auth login', 'unauthorized', 401);
762
+ }
763
+ if (status === 403 || status === 429)
764
+ throw httpFailure(status, headers, text, errors, topLevelMessage);
765
+ if (status === 404) {
766
+ throw new GitHubApiError(firstMessage(errors) ?? topLevelMessage ?? 'GitHub returned HTTP 404 for the request.', 'not_found', 404);
767
+ }
768
+ if (status < 200 || status >= 300) {
769
+ throw new GitHubApiError(`GitHub request failed with HTTP ${String(status)}.`, 'graphql', status);
770
+ }
771
+ if (record === undefined) {
772
+ throw new GitHubApiError('GitHub returned a response that was not JSON.', 'graphql', status);
773
+ }
774
+ return { data: record.data, errors, status };
775
+ }
776
+ /** Throw the classified error of a GraphQL body, or return its data. */
777
+ unwrap(result) {
778
+ const failure = errorFromBody(result.errors, result.status);
779
+ if (failure !== undefined)
780
+ throw failure;
781
+ if (result.data === undefined || result.data === null) {
782
+ throw new GitHubApiError('GitHub returned no data for the request.', 'graphql', result.status);
783
+ }
784
+ return result.data;
785
+ }
786
+ }
787
+ /**
788
+ * Create one GitHub API session.
789
+ * The returned object caches responses for a few minutes and caches the resolved
790
+ * scope catalog for five, so the caller may create it once per resolved token.
791
+ * @param options - the token, host, and optional session-wide abort signal.
792
+ * @returns the API the controller forwards to.
793
+ */
794
+ export function createGitHubApi(options) {
795
+ return new GitHubApiClient(options);
796
+ }
797
+ //# sourceMappingURL=client.js.map