@gitkraken/core-gitlens 0.5.103 → 0.5.105

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 (99) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/git/models/issue.d.ts +78 -0
  3. package/dist/git/models/issue.d.ts.map +1 -1
  4. package/dist/git/models/issue.js.map +1 -1
  5. package/dist/plus/git-github/api/config.d.ts +12 -0
  6. package/dist/plus/git-github/api/config.d.ts.map +1 -1
  7. package/dist/plus/git-github/api/config.js +12 -1
  8. package/dist/plus/git-github/api/config.js.map +1 -1
  9. package/dist/plus/git-github/api/github.d.ts +77 -7
  10. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  11. package/dist/plus/git-github/api/github.js +241 -120
  12. package/dist/plus/git-github/api/github.js.map +1 -1
  13. package/dist/plus/git-github/api/issueSearchQuery.d.ts +70 -0
  14. package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -0
  15. package/dist/plus/git-github/api/issueSearchQuery.js +143 -0
  16. package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -0
  17. package/dist/plus/integrations/authentication/configuredIntegrationService.d.ts +1 -0
  18. package/dist/plus/integrations/authentication/configuredIntegrationService.d.ts.map +1 -1
  19. package/dist/plus/integrations/authentication/configuredIntegrationService.js +4 -1
  20. package/dist/plus/integrations/authentication/configuredIntegrationService.js.map +1 -1
  21. package/dist/plus/integrations/index.d.ts +2 -0
  22. package/dist/plus/integrations/index.d.ts.map +1 -1
  23. package/dist/plus/integrations/integrationService.d.ts +39 -2
  24. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  25. package/dist/plus/integrations/integrationService.js +46 -23
  26. package/dist/plus/integrations/integrationService.js.map +1 -1
  27. package/dist/plus/integrations/manager.d.ts +88 -2
  28. package/dist/plus/integrations/manager.d.ts.map +1 -1
  29. package/dist/plus/integrations/models/gitHostIntegration.d.ts +61 -3
  30. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  31. package/dist/plus/integrations/models/gitHostIntegration.js +51 -0
  32. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  33. package/dist/plus/integrations/models/integration.d.ts +3 -46
  34. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  35. package/dist/plus/integrations/models/integration.js.map +1 -1
  36. package/dist/plus/integrations/models/issueReads.d.ts +70 -0
  37. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -0
  38. package/dist/plus/integrations/models/issueReads.js +2 -0
  39. package/dist/plus/integrations/models/issueReads.js.map +1 -0
  40. package/dist/plus/integrations/providerFilters.d.ts +10 -0
  41. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  42. package/dist/plus/integrations/providers/github.d.ts +26 -3
  43. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  44. package/dist/plus/integrations/providers/github.js +34 -0
  45. package/dist/plus/integrations/providers/github.js.map +1 -1
  46. package/dist/plus/integrations/providers/models.d.ts +22 -0
  47. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  48. package/dist/plus/integrations/providers/models.js +26 -0
  49. package/dist/plus/integrations/providers/models.js.map +1 -1
  50. package/dist/plus/integrations/reads/broaden.d.ts +10 -0
  51. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  52. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  53. package/dist/plus/integrations/reads/counts.d.ts +64 -0
  54. package/dist/plus/integrations/reads/counts.d.ts.map +1 -0
  55. package/dist/plus/integrations/reads/counts.js +164 -0
  56. package/dist/plus/integrations/reads/counts.js.map +1 -0
  57. package/dist/plus/integrations/reads/filters.d.ts +63 -1
  58. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  59. package/dist/plus/integrations/reads/filters.js +125 -0
  60. package/dist/plus/integrations/reads/filters.js.map +1 -1
  61. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  62. package/dist/plus/integrations/reads/issues.js +22 -41
  63. package/dist/plus/integrations/reads/issues.js.map +1 -1
  64. package/dist/plus/integrations/reads/paging.d.ts +59 -0
  65. package/dist/plus/integrations/reads/paging.d.ts.map +1 -1
  66. package/dist/plus/integrations/reads/paging.js +60 -0
  67. package/dist/plus/integrations/reads/paging.js.map +1 -1
  68. package/dist/plus/integrations/reads/searchIssues.d.ts +55 -0
  69. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -0
  70. package/dist/plus/integrations/reads/searchIssues.js +144 -0
  71. package/dist/plus/integrations/reads/searchIssues.js.map +1 -0
  72. package/dist/plus/integrations/reads/warnings.d.ts +25 -1
  73. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  74. package/dist/plus/integrations/reads/warnings.js +86 -2
  75. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  76. package/docs/integrations.md +110 -2
  77. package/docs/kepler-read-api-parity.md +57 -0
  78. package/package.json +1 -1
  79. package/src/git/models/issue.ts +81 -0
  80. package/src/plus/git-github/api/config.ts +13 -0
  81. package/src/plus/git-github/api/github.ts +332 -148
  82. package/src/plus/git-github/api/issueSearchQuery.ts +165 -0
  83. package/src/plus/integrations/authentication/configuredIntegrationService.ts +6 -1
  84. package/src/plus/integrations/index.ts +5 -0
  85. package/src/plus/integrations/integrationService.ts +130 -70
  86. package/src/plus/integrations/manager.ts +88 -2
  87. package/src/plus/integrations/models/gitHostIntegration.ts +102 -2
  88. package/src/plus/integrations/models/integration.ts +7 -46
  89. package/src/plus/integrations/models/issueReads.ts +73 -0
  90. package/src/plus/integrations/providerFilters.ts +15 -0
  91. package/src/plus/integrations/providers/github.ts +64 -2
  92. package/src/plus/integrations/providers/models.ts +49 -0
  93. package/src/plus/integrations/reads/broaden.ts +10 -0
  94. package/src/plus/integrations/reads/counts.ts +285 -0
  95. package/src/plus/integrations/reads/filters.ts +162 -1
  96. package/src/plus/integrations/reads/issues.ts +22 -45
  97. package/src/plus/integrations/reads/paging.ts +102 -0
  98. package/src/plus/integrations/reads/searchIssues.ts +252 -0
  99. package/src/plus/integrations/reads/warnings.ts +114 -4
@@ -23,6 +23,8 @@ import { maybeStopWatch } from '../../../utils/stopwatch.js';
23
23
  import { parseUri } from '../../../utils/uri.js';
24
24
  import { fromString, satisfies } from '../../../utils/version.js';
25
25
  import { fromGitHubIssue, fromGitHubIssueOrPullRequestState, fromGitHubPullRequest, fromGitHubPullRequestLite, } from '../models.js';
26
+ import { githubSearchResultLimit } from './config.js';
27
+ import { gitHubIssueSearchRelationships, toGitHubIssueSearchQualifiers, toGitHubIssueSearchScopeQualifiers, } from './issueSearchQuery.js';
26
28
  const emptyPagedResult = Object.freeze({ values: [] });
27
29
  const emptyBlameResult = Object.freeze({ ranges: [] });
28
30
  // Transient gateway/network failures (e.g. an upstream `502 Bad Gateway`) are worth a few quick
@@ -2203,7 +2205,7 @@ export class GitHubApi {
2203
2205
  values: results,
2204
2206
  cursor: rsp.search.pageInfo.endCursor ?? undefined,
2205
2207
  hasMore: rsp.search.pageInfo.hasNextPage,
2206
- truncated: rsp.search.issueCount > 1000,
2208
+ truncated: rsp.search.issueCount > githubSearchResultLimit,
2207
2209
  };
2208
2210
  }
2209
2211
  catch (ex) {
@@ -2214,7 +2216,164 @@ export class GitHubApi {
2214
2216
  return (await this.searchMyPullRequestsPage(provider, token, options, cancellation)).values;
2215
2217
  }
2216
2218
  async searchMyIssues(provider, token, options, cancellation) {
2219
+ let search = options?.search?.trim() ?? '';
2220
+ if (options?.user) {
2221
+ search += ` user:${options.user}`;
2222
+ }
2223
+ if (options?.repos != null && options.repos.length > 0) {
2224
+ const repo = ' repo:';
2225
+ search += `${repo}${options.repos.join(repo)}`;
2226
+ }
2227
+ const baseFilters = 'type:issue is:open archived:false';
2228
+ // `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
2229
+ // (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
2230
+ // user-relative by definition, so an all-assignees read still only surfaces the current user's authored
2231
+ // and mentioned issues plus every assigned-to-anyone issue.
2232
+ //
2233
+ // NOTE: `assignee:*` requires a SCOPE to be meaningful, but any scope will do — one repository, several,
2234
+ // or an org (measured: `repo:a repo:b … assignee:*` returns exactly the sum of the two per-repo counts).
2235
+ // It is only the UNSCOPED form that is meaningless, matching millions of issues across all of GitHub, so
2236
+ // callers must supply `repos` (or an org qualifier via `search`); the facade refuses the unscoped case.
2237
+ const assignedQualifier = options?.includeAllAssignees ? 'assignee:*' : 'assignee:@me';
2238
+ // A category is read when the caller asked for it AND it hasn't been exhausted (a `null` cursor slot,
2239
+ // which `searchIssuesByAlias` maintains). An excluded category needs no cursor bookkeeping: a missing
2240
+ // response category reads back as `null`, so it stays excluded across continuations on its own.
2241
+ const categories = options?.categories;
2242
+ const requested = {
2243
+ authored: categories?.authored ?? categories == null,
2244
+ assigned: categories?.assigned ?? categories == null,
2245
+ mentioned: categories?.mentioned ?? categories == null,
2246
+ };
2247
+ // Two things are fixed here rather than incidental:
2248
+ // - the alias names are this read's persisted cursor keys, so they can't be renamed;
2249
+ // - the ORDER is assigned → mentioned → authored, which is the order the union is emitted in and, because
2250
+ // the dedupe keeps the first occurrence of a url, also the precedence between the three. An issue that
2251
+ // is both assigned to and authored by the user surfaces as the assigned one.
2252
+ const searches = [];
2253
+ if (requested.assigned) {
2254
+ searches.push({ alias: 'assigned', query: `${search} ${baseFilters} ${assignedQualifier}`.trim() });
2255
+ }
2256
+ if (requested.mentioned) {
2257
+ searches.push({ alias: 'mentioned', query: `${search} ${baseFilters} mentions:@me`.trim() });
2258
+ }
2259
+ if (requested.authored) {
2260
+ searches.push({ alias: 'authored', query: `${search} ${baseFilters} author:@me`.trim() });
2261
+ }
2262
+ return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
2263
+ }
2264
+ /**
2265
+ * The filtered issue search: issues matching structured criteria over a repository/org scope, with no forced
2266
+ * relationship to the current user. The issue counterpart of {@link searchMyPullRequestsPage}, and distinct
2267
+ * from {@link searchMyIssues}, which is permanently bound to `@me`.
2268
+ *
2269
+ * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first). A
2270
+ * consumer's "show the N most recent" policy at GitHub's result ceiling is only correct under a guaranteed
2271
+ * order, and an option would let a caller pick relevance order and then truncate to an arbitrary subset.
2272
+ *
2273
+ * Each requested relationship becomes its own aliased search, unioned and deduped by url; with none, a single
2274
+ * search runs over the scope alone. `criteria.text` and the other free-form values are sanitized so user input
2275
+ * cannot inject a qualifier and re-scope the search — see {@link toGitHubIssueSearchQualifiers}.
2276
+ */
2277
+ async searchIssuesPage(provider, token, options, cancellation) {
2278
+ const base = [
2279
+ ...toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos),
2280
+ ...toGitHubIssueSearchQualifiers(options?.criteria),
2281
+ ].join(' ');
2282
+ // One aliased search per relationship, OR-ed by union. They can't be one query: GitHub AND-s qualifiers,
2283
+ // so `author:@me assignee:@me` would return the intersection — issues the user both opened and is assigned
2284
+ // to — instead of either set. With no relationship the scope + criteria are already the whole query.
2285
+ const relationships = options?.criteria?.relationships;
2286
+ const searches = relationships?.length
2287
+ ? relationships.map(r => ({
2288
+ alias: gitHubIssueSearchRelationships[r].alias,
2289
+ query: `${base} ${gitHubIssueSearchRelationships[r].qualifier}`.trim(),
2290
+ }))
2291
+ : [{ alias: 'matched', query: base }];
2292
+ return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
2293
+ }
2294
+ /**
2295
+ * Counts issues for several scopes in ONE request, transferring no issues at all — each scope is an aliased
2296
+ * `search` selecting only `issueCount`, with `first: 0` so no nodes are fetched.
2297
+ *
2298
+ * This is what makes a "this will fetch ~N issues" preview affordable: measured against the live API, 30
2299
+ * aliased counts cost a single rate-limit point. It is still a network request per chunk, so a caller is
2300
+ * expected to debounce and cache.
2301
+ *
2302
+ * Returns counts positionally — one per input scope, same order — because a caller-supplied key must never
2303
+ * reach the GraphQL document (it would break it); the aliases are generated. `undefined` in a slot means the
2304
+ * response omitted that alias, which the caller reports as "not counted" rather than as zero.
2305
+ */
2306
+ async countIssues(provider, token, scopes, options, cancellation) {
2307
+ const scope = getScopedLogger();
2308
+ if (scopes.length === 0)
2309
+ return [];
2310
+ const queries = scopes.map(s => {
2311
+ const qualifiers = [
2312
+ ...toGitHubIssueSearchScopeQualifiers(s.org, s.repos),
2313
+ ...toGitHubIssueSearchQualifiers(s.criteria),
2314
+ ];
2315
+ // A relationship set is OR-ed across searches, which a single count can't express — the facade splits
2316
+ // such a scope into one count per relationship before calling, so at most one is present here.
2317
+ const relationship = s.criteria?.relationships?.[0];
2318
+ if (relationship != null) {
2319
+ qualifiers.push(gitHubIssueSearchRelationships[relationship].qualifier);
2320
+ }
2321
+ return qualifiers.join(' ');
2322
+ });
2323
+ // Aliases are positional and generated (`s0`, `s1`, …): a caller's key is arbitrary text and would break
2324
+ // the document, so the caller maps results back by index.
2325
+ const params = queries.map((_, i) => `$q${i}: String!`).join('\n\t\t\t\t');
2326
+ // `first: 0` is what makes this cheap — `issueCount` alone, no nodes over the wire.
2327
+ const fields = queries
2328
+ .map((_, i) => `s${i}: search(query: $q${i}, type: ISSUE, first: 0) { issueCount }`)
2329
+ .join('\n\t\t\t\t');
2330
+ const query = `query countIssues(
2331
+ ${params}
2332
+ ) {
2333
+ ${fields}
2334
+ }`;
2335
+ const variables = { baseUrl: options?.baseUrl };
2336
+ queries.forEach((q, i) => {
2337
+ variables[`q${i}`] = q;
2338
+ });
2339
+ try {
2340
+ const rsp = await this.graphql(provider, token, query, variables, scope, cancellation);
2341
+ if (rsp == null)
2342
+ return queries.map(() => undefined);
2343
+ return queries.map((_, i) => rsp[`s${i}`]?.issueCount);
2344
+ }
2345
+ catch (ex) {
2346
+ throw this.handleException(ex, provider, scope);
2347
+ }
2348
+ }
2349
+ /**
2350
+ * The aliased-search engine behind every GitHub issue search: one GraphQL request carrying N independently
2351
+ * cursored `search` fields, `@include`-gated so an exhausted or unrequested one costs nothing.
2352
+ *
2353
+ * It exists as one implementation because the properties that make it correct are subtle and must not be
2354
+ * reproduced per read: each alias advances on its OWN cursor and is dropped from the request once exhausted
2355
+ * (so a finished search is never re-queried, which would re-emit its first page); results are mapped
2356
+ * node-by-node so one unmappable issue can't discard the page; the union is deduped by `url` rather than by
2357
+ * `IssueShape.id`, which for some providers is a per-repository number; and a provider that claims another
2358
+ * page while withholding its `endCursor` is reported as truncated instead of paged forever.
2359
+ *
2360
+ * {@link searchMyIssues} is one configuration of it (its three `@me` categories), and its alias names are
2361
+ * that read's published cursor keys.
2362
+ *
2363
+ * `searches` must have unique aliases, each a valid GraphQL name that is neither `page` nor `truncated` —
2364
+ * the composite cursor keys aliases at its top level, alongside those two reserved fields.
2365
+ */
2366
+ async searchIssuesByAlias(provider, token, searches, options, cancellation) {
2217
2367
  const scope = getScopedLogger();
2368
+ // Enforced, not just documented: aliases share the cursor's top level with `page` and `truncated`, so an
2369
+ // alias colliding with either would overwrite it — and the failure would be SILENT, a page number replaced
2370
+ // by a cursor string that reads back as page 1, restarting the walk with no error and no truncation flag.
2371
+ // Cheap to check, and it fails at the one call that introduced the collision rather than in a consumer's
2372
+ // persisted cursor.
2373
+ const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated');
2374
+ if (reserved.length > 0) {
2375
+ throw new Error(`Issue search alias(es) ${reserved.map(s => `'${s.alias}'`).join(', ')} collide with the composite cursor's reserved keys`);
2376
+ }
2218
2377
  let cursor;
2219
2378
  if (options?.cursor != null) {
2220
2379
  try {
@@ -2223,37 +2382,34 @@ export class GitHubApi {
2223
2382
  catch { }
2224
2383
  }
2225
2384
  const page = Math.max(1, Math.trunc(cursor?.page ?? 1));
2226
- // A category is read when the caller asked for it AND it hasn't been exhausted (`null` cursor slot). An
2227
- // excluded category needs no cursor bookkeeping: `nextCategoryCursor` reads a missing response category as
2228
- // `null`, so it stays excluded across continuations on its own.
2229
- const categories = options?.categories;
2230
- const requested = {
2231
- authored: categories?.authored ?? categories == null,
2232
- assigned: categories?.assigned ?? categories == null,
2233
- mentioned: categories?.mentioned ?? categories == null,
2385
+ // A slot is a continuation string, `null` (exhausted), or absent. Anything else came from a malformed or
2386
+ // foreign cursor, and is read as absent rather than threaded back into the request as a continuation.
2387
+ const slotFor = (alias) => {
2388
+ const slot = cursor?.[alias];
2389
+ return slot === null || typeof slot === 'string' ? slot : undefined;
2234
2390
  };
2235
- const includeAuthored = requested.authored && cursor?.authored !== null;
2236
- const includeAssigned = requested.assigned && cursor?.assigned !== null;
2237
- const includeMentioned = requested.mentioned && cursor?.mentioned !== null;
2238
- // Reading nothing is the honest answer to "none of the three": widening back to the union would return
2239
- // issues the caller explicitly excluded.
2240
- if (!includeAuthored && !includeAssigned && !includeMentioned) {
2391
+ // Every requested search is in the document; an exhausted one is switched OFF by its `@include` gate
2392
+ // rather than removed, so a continuation's request keeps the shape of the first one and GitHub's
2393
+ // query-document cache still recognizes it. `active` is what actually runs.
2394
+ const isActive = (s) => slotFor(s.alias) !== null;
2395
+ const active = searches.filter(isActive);
2396
+ // Reading nothing is the honest answer to "every requested search is exhausted (or none was requested)":
2397
+ // widening back to the full set would return items the caller excluded, or re-emit a finished page.
2398
+ if (active.length === 0) {
2241
2399
  return { values: [], hasMore: false, page: page, truncated: cursor?.truncated === true };
2242
2400
  }
2243
- const query = `query searchMyIssues(
2244
- $authored: String!
2245
- $assigned: String!
2246
- $mentioned: String!
2247
- $authoredCursor: String
2248
- $assignedCursor: String
2249
- $mentionedCursor: String
2250
- $includeAuthored: Boolean!
2251
- $includeAssigned: Boolean!
2252
- $includeMentioned: Boolean!
2253
- $avatarSize: Int
2254
- ) {
2255
- authored: search(first: 100, after: $authoredCursor, query: $authored, type: ISSUE)
2256
- @include(if: $includeAuthored) {
2401
+ // GitHub's search caps a page at 100 regardless of what is asked for.
2402
+ const pageSize = Math.min(100, Math.max(1, Math.trunc(options?.pageSize ?? 100)));
2403
+ // `includeAssigned` etc. — the same variable names the read published before the aliases were made
2404
+ // data-driven, so a recorded/asserted request shape stays recognizable.
2405
+ const includeVar = (alias) => `include${alias.charAt(0).toUpperCase()}${alias.slice(1)}`;
2406
+ const params = searches.flatMap(s => [
2407
+ `$${s.alias}: String!`,
2408
+ `$${s.alias}Cursor: String`,
2409
+ `$${includeVar(s.alias)}: Boolean!`,
2410
+ ]);
2411
+ const fields = searches.map(s => `${s.alias}: search(first: ${pageSize}, after: $${s.alias}Cursor, query: $${s.alias}, type: ISSUE)
2412
+ @include(if: $${includeVar(s.alias)}) {
2257
2413
  issueCount
2258
2414
  pageInfo {
2259
2415
  endCursor
@@ -2265,108 +2421,66 @@ export class GitHubApi {
2265
2421
  ${options?.includeBody ? 'body' : ''}
2266
2422
  }
2267
2423
  }
2268
- }
2269
- assigned: search(first: 100, after: $assignedCursor, query: $assigned, type: ISSUE)
2270
- @include(if: $includeAssigned) {
2271
- issueCount
2272
- pageInfo {
2273
- endCursor
2274
- hasNextPage
2275
- }
2276
- nodes {
2277
- ... on Issue {
2278
- ${gqIssueFragment}
2279
- ${options?.includeBody ? 'body' : ''}
2280
- }
2281
- }
2282
- }
2283
- mentioned: search(first: 100, after: $mentionedCursor, query: $mentioned, type: ISSUE)
2284
- @include(if: $includeMentioned) {
2285
- issueCount
2286
- pageInfo {
2287
- endCursor
2288
- hasNextPage
2289
- }
2290
- nodes {
2291
- ... on Issue {
2292
- ${gqIssueFragment}
2293
- ${options?.includeBody ? 'body' : ''}
2294
- }
2295
- }
2296
- }
2424
+ }`);
2425
+ const query = `query searchIssues(
2426
+ ${params.join('\n\t\t\t\t')}
2427
+ $avatarSize: Int
2428
+ ) {
2429
+ ${fields.join('\n\t\t\t\t')}
2297
2430
  }`;
2298
- let search = options?.search?.trim() ?? '';
2299
- if (options?.user) {
2300
- search += ` user:${options.user}`;
2301
- }
2302
- if (options?.repos != null && options.repos.length > 0) {
2303
- const repo = ' repo:';
2304
- search += `${repo}${options.repos.join(repo)}`;
2431
+ const variables = {
2432
+ baseUrl: options?.baseUrl,
2433
+ avatarSize: options?.avatarSize,
2434
+ };
2435
+ for (const s of searches) {
2436
+ variables[s.alias] = s.query;
2437
+ variables[`${s.alias}Cursor`] = slotFor(s.alias) ?? undefined;
2438
+ variables[includeVar(s.alias)] = isActive(s);
2305
2439
  }
2306
- const baseFilters = 'type:issue is:open archived:false';
2307
- // `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
2308
- // (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
2309
- // user-relative by definition, so an all-assignees read still only surfaces the current user's authored
2310
- // and mentioned issues plus every assigned-to-anyone issue.
2311
- //
2312
- // NOTE: GitHub only honors `assignee:*` when the search is scoped to a single repository (see GitHub's
2313
- // issue-search docs). Callers using `includeAllAssignees` must scope the read via `repos` (the broaden
2314
- // fan-out does this per org/repo); an unscoped account-wide `assignee:*` is not meaningful to GitHub.
2315
- const assignedQualifier = options?.includeAllAssignees ? 'assignee:*' : 'assignee:@me';
2316
2440
  try {
2317
- const rsp = await this.graphql(provider, token, query, {
2318
- authored: `${search} ${baseFilters} author:@me`.trim(),
2319
- assigned: `${search} ${baseFilters} ${assignedQualifier}`.trim(),
2320
- mentioned: `${search} ${baseFilters} mentions:@me`.trim(),
2321
- authoredCursor: cursor?.authored ?? undefined,
2322
- assignedCursor: cursor?.assigned ?? undefined,
2323
- mentionedCursor: cursor?.mentioned ?? undefined,
2324
- includeAuthored: includeAuthored,
2325
- includeAssigned: includeAssigned,
2326
- includeMentioned: includeMentioned,
2327
- baseUrl: options?.baseUrl,
2328
- avatarSize: options?.avatarSize,
2329
- }, scope, cancellation);
2441
+ const rsp = await this.graphql(provider, token, query, variables, scope, cancellation);
2330
2442
  if (rsp == null)
2331
2443
  return { values: [], hasMore: false, page: page, truncated: false };
2332
2444
  // Map node-by-node so one unmappable issue can't discard the whole result set
2333
2445
  const issues = [];
2334
- for (const node of [
2335
- ...(rsp.assigned?.nodes ?? []),
2336
- ...(rsp.mentioned?.nodes ?? []),
2337
- ...(rsp.authored?.nodes ?? []),
2338
- ]) {
2339
- if (node?.id == null)
2340
- continue;
2341
- try {
2342
- issues.push(fromGitHubIssue(node, provider));
2343
- }
2344
- catch (ex) {
2345
- scope?.warn(`skipped unmappable issue; id=${node.id}, url=${node.url}, ex=${ex}`);
2446
+ for (const s of active) {
2447
+ for (const node of rsp[s.alias]?.nodes ?? []) {
2448
+ if (node?.id == null)
2449
+ continue;
2450
+ try {
2451
+ issues.push(fromGitHubIssue(node, provider));
2452
+ }
2453
+ catch (ex) {
2454
+ scope?.warn(`skipped unmappable issue; id=${node.id}, url=${node.url}, ex=${ex}`);
2455
+ }
2346
2456
  }
2347
2457
  }
2458
+ // Dedupe by `url`, not `IssueShape.id`: for some providers `id` is a per-repository number, so an
2459
+ // id-keyed map would collapse distinct issues across repositories.
2348
2460
  const results = uniqueBy(issues, r => r.url, (original, _current) => original);
2349
- const nextCategoryCursor = (category) => {
2350
- if (category?.pageInfo?.hasNextPage && category.pageInfo.endCursor) {
2351
- return category.pageInfo.endCursor;
2461
+ // Every alias gets a slot, so an inactive one keeps its `null` and stays out of the next request. A
2462
+ // missing slot would be read as "never requested", which for a `searches` set that still lists it
2463
+ // would restart it from its first page.
2464
+ const next = { page: page + 1 };
2465
+ let hasMore = false;
2466
+ let continuationMissing = false;
2467
+ let maxIssueCount = 0;
2468
+ for (const s of searches) {
2469
+ const category = rsp[s.alias];
2470
+ const endCursor = category?.pageInfo?.hasNextPage && category.pageInfo.endCursor ? category.pageInfo.endCursor : null;
2471
+ next[s.alias] = endCursor;
2472
+ if (endCursor != null) {
2473
+ hasMore = true;
2352
2474
  }
2353
- return null;
2354
- };
2355
- const next = {
2356
- page: page + 1,
2357
- authored: nextCategoryCursor(rsp.authored),
2358
- assigned: nextCategoryCursor(rsp.assigned),
2359
- mentioned: nextCategoryCursor(rsp.mentioned),
2360
- };
2361
- const hasMore = next.authored != null || next.assigned != null || next.mentioned != null;
2362
- // GitHub search exposes at most 1,000 results per category. Paging removes the previous 100-item
2363
- // truncation; only the upstream search ceiling or an unusable continuation remains incomplete.
2364
- const continuationMissing = [rsp.authored, rsp.assigned, rsp.mentioned].some(category => category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor == null);
2365
- const truncated = cursor?.truncated === true ||
2366
- (rsp.authored?.issueCount ?? 0) > 1000 ||
2367
- (rsp.assigned?.issueCount ?? 0) > 1000 ||
2368
- (rsp.mentioned?.issueCount ?? 0) > 1000 ||
2369
- continuationMissing;
2475
+ if (category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor == null) {
2476
+ continuationMissing = true;
2477
+ }
2478
+ maxIssueCount = Math.max(maxIssueCount, category?.issueCount ?? 0);
2479
+ }
2480
+ // GitHub search exposes at most `githubSearchResultLimit` results PER SEARCH, so the ceiling is
2481
+ // reached as soon as any one alias exceeds it. Paging removes the old 100-item truncation; only that
2482
+ // upstream ceiling or an unusable continuation leaves the read incomplete.
2483
+ const truncated = cursor?.truncated === true || maxIssueCount > githubSearchResultLimit || continuationMissing;
2370
2484
  next.truncated = truncated || undefined;
2371
2485
  return {
2372
2486
  values: [...results],
@@ -2374,6 +2488,7 @@ export class GitHubApi {
2374
2488
  hasMore: hasMore,
2375
2489
  page: page,
2376
2490
  truncated: truncated,
2491
+ totalCount: maxIssueCount,
2377
2492
  };
2378
2493
  }
2379
2494
  catch (ex) {
@@ -2772,6 +2887,12 @@ __decorate([
2772
2887
  __decorate([
2773
2888
  trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
2774
2889
  ], GitHubApi.prototype, "searchMyIssues", null);
2890
+ __decorate([
2891
+ trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
2892
+ ], GitHubApi.prototype, "searchIssuesPage", null);
2893
+ __decorate([
2894
+ trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
2895
+ ], GitHubApi.prototype, "countIssues", null);
2775
2896
  __decorate([
2776
2897
  trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
2777
2898
  ], GitHubApi.prototype, "searchPullRequests", null);