@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
@@ -11,7 +11,7 @@ import {
11
11
  } from '../../../git/errors.js';
12
12
  import type { Account, UnidentifiedAuthor } from '../../../git/models/author.js';
13
13
  import type { DefaultBranch } from '../../../git/models/defaultBranch.js';
14
- import type { Issue, IssueShape } from '../../../git/models/issue.js';
14
+ import type { Issue, IssueSearchCriteria, IssueShape } from '../../../git/models/issue.js';
15
15
  import type { IssueOrPullRequest } from '../../../git/models/issueOrPullRequest.js';
16
16
  import type { PullRequest, PullRequestState, PullRequestStateFilter } from '../../../git/models/pullRequest.js';
17
17
  import { PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
@@ -66,6 +66,12 @@ import {
66
66
  fromGitHubPullRequestLite,
67
67
  } from '../models.js';
68
68
  import type { GitHubApiConfig } from './config.js';
69
+ import { githubSearchResultLimit } from './config.js';
70
+ import {
71
+ gitHubIssueSearchRelationships,
72
+ toGitHubIssueSearchQualifiers,
73
+ toGitHubIssueSearchScopeQualifiers,
74
+ } from './issueSearchQuery.js';
69
75
  import type { GitHubTokenInfo } from './token.js';
70
76
 
71
77
  const emptyPagedResult: PagedResult<any> = Object.freeze({ values: [] });
@@ -133,6 +139,31 @@ function getRequestRetryDelay(attempt: number): number {
133
139
  return Math.round(backoff / 2 + Math.random() * (backoff / 2));
134
140
  }
135
141
 
142
+ /** One aliased `search` field in a multi-search issue request; see {@link GitHubApi.searchIssuesByAlias}. */
143
+ interface AliasedIssueSearch {
144
+ /** GraphQL alias, also the key this search's cursor is stored under. Must not be `page` or `truncated`. */
145
+ alias: string;
146
+ /** The fully-composed GitHub search query, qualifiers included. */
147
+ query: string;
148
+ }
149
+
150
+ /** One page of a multi-search issue request, with its composite cursor across every alias. */
151
+ export interface AliasedIssueSearchResult {
152
+ values: IssueShape[];
153
+ /** Opaque composite cursor; absent when every alias is exhausted. */
154
+ cursor?: string;
155
+ hasMore: boolean;
156
+ page: number;
157
+ /** True when the read cannot return everything — GitHub's search ceiling, or an unusable continuation. */
158
+ truncated: boolean;
159
+ /**
160
+ * The largest `issueCount` any single alias reported, which is what {@link githubSearchResultLimit} applies
161
+ * to (the ceiling is per search, not per request). Absent when no request was made — every alias was already
162
+ * exhausted — so `undefined` means "not reported", never zero matches.
163
+ */
164
+ totalCount?: number;
165
+ }
166
+
136
167
  // Matches a GraphQL document whose operation is definitely a read query — the `query` keyword as
137
168
  // the first significant token, after any leading whitespace or `#` comment lines. We retry only
138
169
  // when this matches; a mutation (even one prefixed by a comment), a subscription, or anything we
@@ -3569,7 +3600,7 @@ export class GitHubApi {
3569
3600
  values: results,
3570
3601
  cursor: rsp.search.pageInfo.endCursor ?? undefined,
3571
3602
  hasMore: rsp.search.pageInfo.hasNextPage,
3572
- truncated: rsp.search.issueCount > 1000,
3603
+ truncated: rsp.search.issueCount > githubSearchResultLimit,
3573
3604
  };
3574
3605
  } catch (ex) {
3575
3606
  throw this.handleException(ex, provider, scope, options?.silent);
@@ -3620,37 +3651,235 @@ export class GitHubApi {
3620
3651
  categories?: { authored?: boolean; assigned?: boolean; mentioned?: boolean };
3621
3652
  },
3622
3653
  cancellation?: AbortSignal,
3623
- ): Promise<
3624
- | {
3625
- values: IssueShape[];
3626
- cursor?: string;
3627
- hasMore: boolean;
3628
- page: number;
3629
- truncated: boolean;
3630
- }
3631
- | undefined
3632
- > {
3654
+ ): Promise<AliasedIssueSearchResult | undefined> {
3655
+ let search = options?.search?.trim() ?? '';
3656
+
3657
+ if (options?.user) {
3658
+ search += ` user:${options.user}`;
3659
+ }
3660
+
3661
+ if (options?.repos != null && options.repos.length > 0) {
3662
+ const repo = ' repo:';
3663
+ search += `${repo}${options.repos.join(repo)}`;
3664
+ }
3665
+
3666
+ const baseFilters = 'type:issue is:open archived:false';
3667
+ // `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
3668
+ // (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
3669
+ // user-relative by definition, so an all-assignees read still only surfaces the current user's authored
3670
+ // and mentioned issues plus every assigned-to-anyone issue.
3671
+ //
3672
+ // NOTE: `assignee:*` requires a SCOPE to be meaningful, but any scope will do — one repository, several,
3673
+ // or an org (measured: `repo:a repo:b … assignee:*` returns exactly the sum of the two per-repo counts).
3674
+ // It is only the UNSCOPED form that is meaningless, matching millions of issues across all of GitHub, so
3675
+ // callers must supply `repos` (or an org qualifier via `search`); the facade refuses the unscoped case.
3676
+ const assignedQualifier = options?.includeAllAssignees ? 'assignee:*' : 'assignee:@me';
3677
+
3678
+ // A category is read when the caller asked for it AND it hasn't been exhausted (a `null` cursor slot,
3679
+ // which `searchIssuesByAlias` maintains). An excluded category needs no cursor bookkeeping: a missing
3680
+ // response category reads back as `null`, so it stays excluded across continuations on its own.
3681
+ const categories = options?.categories;
3682
+ const requested = {
3683
+ authored: categories?.authored ?? categories == null,
3684
+ assigned: categories?.assigned ?? categories == null,
3685
+ mentioned: categories?.mentioned ?? categories == null,
3686
+ };
3687
+
3688
+ // Two things are fixed here rather than incidental:
3689
+ // - the alias names are this read's persisted cursor keys, so they can't be renamed;
3690
+ // - the ORDER is assigned → mentioned → authored, which is the order the union is emitted in and, because
3691
+ // the dedupe keeps the first occurrence of a url, also the precedence between the three. An issue that
3692
+ // is both assigned to and authored by the user surfaces as the assigned one.
3693
+ const searches: AliasedIssueSearch[] = [];
3694
+ if (requested.assigned) {
3695
+ searches.push({ alias: 'assigned', query: `${search} ${baseFilters} ${assignedQualifier}`.trim() });
3696
+ }
3697
+ if (requested.mentioned) {
3698
+ searches.push({ alias: 'mentioned', query: `${search} ${baseFilters} mentions:@me`.trim() });
3699
+ }
3700
+ if (requested.authored) {
3701
+ searches.push({ alias: 'authored', query: `${search} ${baseFilters} author:@me`.trim() });
3702
+ }
3703
+
3704
+ return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
3705
+ }
3706
+
3707
+ /**
3708
+ * The filtered issue search: issues matching structured criteria over a repository/org scope, with no forced
3709
+ * relationship to the current user. The issue counterpart of {@link searchMyPullRequestsPage}, and distinct
3710
+ * from {@link searchMyIssues}, which is permanently bound to `@me`.
3711
+ *
3712
+ * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first). A
3713
+ * consumer's "show the N most recent" policy at GitHub's result ceiling is only correct under a guaranteed
3714
+ * order, and an option would let a caller pick relevance order and then truncate to an arbitrary subset.
3715
+ *
3716
+ * Each requested relationship becomes its own aliased search, unioned and deduped by url; with none, a single
3717
+ * search runs over the scope alone. `criteria.text` and the other free-form values are sanitized so user input
3718
+ * cannot inject a qualifier and re-scope the search — see {@link toGitHubIssueSearchQualifiers}.
3719
+ */
3720
+ @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3721
+ async searchIssuesPage(
3722
+ provider: Provider,
3723
+ token: GitHubTokenInfo,
3724
+ options?: {
3725
+ repos?: string[];
3726
+ org?: string;
3727
+ criteria?: IssueSearchCriteria;
3728
+ baseUrl?: string;
3729
+ avatarSize?: number;
3730
+ includeBody?: boolean;
3731
+ cursor?: string;
3732
+ pageSize?: number;
3733
+ },
3734
+ cancellation?: AbortSignal,
3735
+ ): Promise<AliasedIssueSearchResult | undefined> {
3736
+ const base = [
3737
+ ...toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos),
3738
+ ...toGitHubIssueSearchQualifiers(options?.criteria),
3739
+ ].join(' ');
3740
+
3741
+ // One aliased search per relationship, OR-ed by union. They can't be one query: GitHub AND-s qualifiers,
3742
+ // so `author:@me assignee:@me` would return the intersection — issues the user both opened and is assigned
3743
+ // to — instead of either set. With no relationship the scope + criteria are already the whole query.
3744
+ const relationships = options?.criteria?.relationships;
3745
+ const searches: AliasedIssueSearch[] = relationships?.length
3746
+ ? relationships.map(r => ({
3747
+ alias: gitHubIssueSearchRelationships[r].alias,
3748
+ query: `${base} ${gitHubIssueSearchRelationships[r].qualifier}`.trim(),
3749
+ }))
3750
+ : [{ alias: 'matched', query: base }];
3751
+
3752
+ return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
3753
+ }
3754
+
3755
+ /**
3756
+ * Counts issues for several scopes in ONE request, transferring no issues at all — each scope is an aliased
3757
+ * `search` selecting only `issueCount`, with `first: 0` so no nodes are fetched.
3758
+ *
3759
+ * This is what makes a "this will fetch ~N issues" preview affordable: measured against the live API, 30
3760
+ * aliased counts cost a single rate-limit point. It is still a network request per chunk, so a caller is
3761
+ * expected to debounce and cache.
3762
+ *
3763
+ * Returns counts positionally — one per input scope, same order — because a caller-supplied key must never
3764
+ * reach the GraphQL document (it would break it); the aliases are generated. `undefined` in a slot means the
3765
+ * response omitted that alias, which the caller reports as "not counted" rather than as zero.
3766
+ */
3767
+ @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3768
+ async countIssues(
3769
+ provider: Provider,
3770
+ token: GitHubTokenInfo,
3771
+ scopes: readonly { repos?: string[]; org?: string; criteria?: IssueSearchCriteria }[],
3772
+ options?: { baseUrl?: string },
3773
+ cancellation?: AbortSignal,
3774
+ ): Promise<(number | undefined)[]> {
3775
+ const scope = getScopedLogger();
3776
+ if (scopes.length === 0) return [];
3777
+
3778
+ const queries = scopes.map(s => {
3779
+ const qualifiers = [
3780
+ ...toGitHubIssueSearchScopeQualifiers(s.org, s.repos),
3781
+ ...toGitHubIssueSearchQualifiers(s.criteria),
3782
+ ];
3783
+
3784
+ // A relationship set is OR-ed across searches, which a single count can't express — the facade splits
3785
+ // such a scope into one count per relationship before calling, so at most one is present here.
3786
+ const relationship = s.criteria?.relationships?.[0];
3787
+ if (relationship != null) {
3788
+ qualifiers.push(gitHubIssueSearchRelationships[relationship].qualifier);
3789
+ }
3790
+ return qualifiers.join(' ');
3791
+ });
3792
+
3793
+ // Aliases are positional and generated (`s0`, `s1`, …): a caller's key is arbitrary text and would break
3794
+ // the document, so the caller maps results back by index.
3795
+ const params = queries.map((_, i) => `$q${i}: String!`).join('\n\t\t\t\t');
3796
+ // `first: 0` is what makes this cheap — `issueCount` alone, no nodes over the wire.
3797
+ const fields = queries
3798
+ .map((_, i) => `s${i}: search(query: $q${i}, type: ISSUE, first: 0) { issueCount }`)
3799
+ .join('\n\t\t\t\t');
3800
+ const query = `query countIssues(
3801
+ ${params}
3802
+ ) {
3803
+ ${fields}
3804
+ }`;
3805
+
3806
+ const variables: Record<string, unknown> = { baseUrl: options?.baseUrl };
3807
+ queries.forEach((q, i) => {
3808
+ variables[`q${i}`] = q;
3809
+ });
3810
+
3811
+ try {
3812
+ const rsp = await this.graphql<Record<string, { issueCount?: number } | undefined>>(
3813
+ provider,
3814
+ token,
3815
+ query,
3816
+ variables,
3817
+ scope,
3818
+ cancellation,
3819
+ );
3820
+ if (rsp == null) return queries.map(() => undefined);
3821
+
3822
+ return queries.map((_, i) => rsp[`s${i}`]?.issueCount);
3823
+ } catch (ex) {
3824
+ throw this.handleException(ex, provider, scope);
3825
+ }
3826
+ }
3827
+
3828
+ /**
3829
+ * The aliased-search engine behind every GitHub issue search: one GraphQL request carrying N independently
3830
+ * cursored `search` fields, `@include`-gated so an exhausted or unrequested one costs nothing.
3831
+ *
3832
+ * It exists as one implementation because the properties that make it correct are subtle and must not be
3833
+ * reproduced per read: each alias advances on its OWN cursor and is dropped from the request once exhausted
3834
+ * (so a finished search is never re-queried, which would re-emit its first page); results are mapped
3835
+ * node-by-node so one unmappable issue can't discard the page; the union is deduped by `url` rather than by
3836
+ * `IssueShape.id`, which for some providers is a per-repository number; and a provider that claims another
3837
+ * page while withholding its `endCursor` is reported as truncated instead of paged forever.
3838
+ *
3839
+ * {@link searchMyIssues} is one configuration of it (its three `@me` categories), and its alias names are
3840
+ * that read's published cursor keys.
3841
+ *
3842
+ * `searches` must have unique aliases, each a valid GraphQL name that is neither `page` nor `truncated` —
3843
+ * the composite cursor keys aliases at its top level, alongside those two reserved fields.
3844
+ */
3845
+ private async searchIssuesByAlias(
3846
+ provider: Provider,
3847
+ token: GitHubTokenInfo,
3848
+ searches: readonly AliasedIssueSearch[],
3849
+ options?: { baseUrl?: string; avatarSize?: number; includeBody?: boolean; cursor?: string; pageSize?: number },
3850
+ cancellation?: AbortSignal,
3851
+ ): Promise<AliasedIssueSearchResult | undefined> {
3633
3852
  const scope = getScopedLogger();
3634
3853
 
3635
3854
  type SearchCategory = {
3636
3855
  issueCount: number;
3637
- pageInfo?: {
3638
- endCursor?: string | null;
3639
- hasNextPage: boolean;
3640
- };
3856
+ pageInfo?: { endCursor?: string | null; hasNextPage: boolean };
3641
3857
  nodes: (GitHubIssue | null)[] | null;
3642
3858
  };
3643
- interface SearchResult {
3644
- authored?: SearchCategory;
3645
- assigned?: SearchCategory;
3646
- mentioned?: SearchCategory;
3647
- }
3859
+ /**
3860
+ * Aliases are keyed at the TOP LEVEL, alongside `page` and `truncated` — the format
3861
+ * {@link searchMyIssues} has always published, so it stays flat rather than nesting the aliases: a
3862
+ * consumer's persisted cursor has to keep resuming where it left off.
3863
+ *
3864
+ * A slot of `null` means exhausted and a missing slot means never requested; both keep that alias out of
3865
+ * the next request.
3866
+ */
3648
3867
  interface SearchCursor {
3649
3868
  page?: number;
3650
- authored?: string | null;
3651
- assigned?: string | null;
3652
- mentioned?: string | null;
3653
3869
  truncated?: boolean;
3870
+ [alias: string]: string | number | boolean | null | undefined;
3871
+ }
3872
+
3873
+ // Enforced, not just documented: aliases share the cursor's top level with `page` and `truncated`, so an
3874
+ // alias colliding with either would overwrite it — and the failure would be SILENT, a page number replaced
3875
+ // by a cursor string that reads back as page 1, restarting the walk with no error and no truncation flag.
3876
+ // Cheap to check, and it fails at the one call that introduced the collision rather than in a consumer's
3877
+ // persisted cursor.
3878
+ const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated');
3879
+ if (reserved.length > 0) {
3880
+ throw new Error(
3881
+ `Issue search alias(es) ${reserved.map(s => `'${s.alias}'`).join(', ')} collide with the composite cursor's reserved keys`,
3882
+ );
3654
3883
  }
3655
3884
 
3656
3885
  let cursor: SearchCursor | undefined;
@@ -3660,52 +3889,36 @@ export class GitHubApi {
3660
3889
  } catch {}
3661
3890
  }
3662
3891
  const page = Math.max(1, Math.trunc(cursor?.page ?? 1));
3663
- // A category is read when the caller asked for it AND it hasn't been exhausted (`null` cursor slot). An
3664
- // excluded category needs no cursor bookkeeping: `nextCategoryCursor` reads a missing response category as
3665
- // `null`, so it stays excluded across continuations on its own.
3666
- const categories = options?.categories;
3667
- const requested = {
3668
- authored: categories?.authored ?? categories == null,
3669
- assigned: categories?.assigned ?? categories == null,
3670
- mentioned: categories?.mentioned ?? categories == null,
3892
+ // A slot is a continuation string, `null` (exhausted), or absent. Anything else came from a malformed or
3893
+ // foreign cursor, and is read as absent rather than threaded back into the request as a continuation.
3894
+ const slotFor = (alias: string): string | null | undefined => {
3895
+ const slot = cursor?.[alias];
3896
+ return slot === null || typeof slot === 'string' ? slot : undefined;
3671
3897
  };
3672
- const includeAuthored = requested.authored && cursor?.authored !== null;
3673
- const includeAssigned = requested.assigned && cursor?.assigned !== null;
3674
- const includeMentioned = requested.mentioned && cursor?.mentioned !== null;
3675
- // Reading nothing is the honest answer to "none of the three": widening back to the union would return
3676
- // issues the caller explicitly excluded.
3677
- if (!includeAuthored && !includeAssigned && !includeMentioned) {
3898
+ // Every requested search is in the document; an exhausted one is switched OFF by its `@include` gate
3899
+ // rather than removed, so a continuation's request keeps the shape of the first one and GitHub's
3900
+ // query-document cache still recognizes it. `active` is what actually runs.
3901
+ const isActive = (s: AliasedIssueSearch) => slotFor(s.alias) !== null;
3902
+ const active = searches.filter(isActive);
3903
+ // Reading nothing is the honest answer to "every requested search is exhausted (or none was requested)":
3904
+ // widening back to the full set would return items the caller excluded, or re-emit a finished page.
3905
+ if (active.length === 0) {
3678
3906
  return { values: [], hasMore: false, page: page, truncated: cursor?.truncated === true };
3679
3907
  }
3680
3908
 
3681
- const query = `query searchMyIssues(
3682
- $authored: String!
3683
- $assigned: String!
3684
- $mentioned: String!
3685
- $authoredCursor: String
3686
- $assignedCursor: String
3687
- $mentionedCursor: String
3688
- $includeAuthored: Boolean!
3689
- $includeAssigned: Boolean!
3690
- $includeMentioned: Boolean!
3691
- $avatarSize: Int
3692
- ) {
3693
- authored: search(first: 100, after: $authoredCursor, query: $authored, type: ISSUE)
3694
- @include(if: $includeAuthored) {
3695
- issueCount
3696
- pageInfo {
3697
- endCursor
3698
- hasNextPage
3699
- }
3700
- nodes {
3701
- ... on Issue {
3702
- ${gqIssueFragment}
3703
- ${options?.includeBody ? 'body' : ''}
3704
- }
3705
- }
3706
- }
3707
- assigned: search(first: 100, after: $assignedCursor, query: $assigned, type: ISSUE)
3708
- @include(if: $includeAssigned) {
3909
+ // GitHub's search caps a page at 100 regardless of what is asked for.
3910
+ const pageSize = Math.min(100, Math.max(1, Math.trunc(options?.pageSize ?? 100)));
3911
+ // `includeAssigned` etc. — the same variable names the read published before the aliases were made
3912
+ // data-driven, so a recorded/asserted request shape stays recognizable.
3913
+ const includeVar = (alias: string) => `include${alias.charAt(0).toUpperCase()}${alias.slice(1)}`;
3914
+ const params = searches.flatMap(s => [
3915
+ `$${s.alias}: String!`,
3916
+ `$${s.alias}Cursor: String`,
3917
+ `$${includeVar(s.alias)}: Boolean!`,
3918
+ ]);
3919
+ const fields = searches.map(
3920
+ s => `${s.alias}: search(first: ${pageSize}, after: $${s.alias}Cursor, query: $${s.alias}, type: ISSUE)
3921
+ @include(if: $${includeVar(s.alias)}) {
3709
3922
  issueCount
3710
3923
  pageInfo {
3711
3924
  endCursor
@@ -3717,114 +3930,84 @@ export class GitHubApi {
3717
3930
  ${options?.includeBody ? 'body' : ''}
3718
3931
  }
3719
3932
  }
3720
- }
3721
- mentioned: search(first: 100, after: $mentionedCursor, query: $mentioned, type: ISSUE)
3722
- @include(if: $includeMentioned) {
3723
- issueCount
3724
- pageInfo {
3725
- endCursor
3726
- hasNextPage
3727
- }
3728
- nodes {
3729
- ... on Issue {
3730
- ${gqIssueFragment}
3731
- ${options?.includeBody ? 'body' : ''}
3732
- }
3733
- }
3734
- }
3933
+ }`,
3934
+ );
3935
+ const query = `query searchIssues(
3936
+ ${params.join('\n\t\t\t\t')}
3937
+ $avatarSize: Int
3938
+ ) {
3939
+ ${fields.join('\n\t\t\t\t')}
3735
3940
  }`;
3736
3941
 
3737
- let search = options?.search?.trim() ?? '';
3738
-
3739
- if (options?.user) {
3740
- search += ` user:${options.user}`;
3741
- }
3742
-
3743
- if (options?.repos != null && options.repos.length > 0) {
3744
- const repo = ' repo:';
3745
- search += `${repo}${options.repos.join(repo)}`;
3942
+ const variables: Record<string, unknown> = {
3943
+ baseUrl: options?.baseUrl,
3944
+ avatarSize: options?.avatarSize,
3945
+ };
3946
+ for (const s of searches) {
3947
+ variables[s.alias] = s.query;
3948
+ variables[`${s.alias}Cursor`] = slotFor(s.alias) ?? undefined;
3949
+ variables[includeVar(s.alias)] = isActive(s);
3746
3950
  }
3747
3951
 
3748
- const baseFilters = 'type:issue is:open archived:false';
3749
- // `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
3750
- // (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
3751
- // user-relative by definition, so an all-assignees read still only surfaces the current user's authored
3752
- // and mentioned issues plus every assigned-to-anyone issue.
3753
- //
3754
- // NOTE: GitHub only honors `assignee:*` when the search is scoped to a single repository (see GitHub's
3755
- // issue-search docs). Callers using `includeAllAssignees` must scope the read via `repos` (the broaden
3756
- // fan-out does this per org/repo); an unscoped account-wide `assignee:*` is not meaningful to GitHub.
3757
- const assignedQualifier = options?.includeAllAssignees ? 'assignee:*' : 'assignee:@me';
3758
3952
  try {
3759
- const rsp = await this.graphql<SearchResult>(
3953
+ const rsp = await this.graphql<Record<string, SearchCategory | undefined>>(
3760
3954
  provider,
3761
3955
  token,
3762
3956
  query,
3763
- {
3764
- authored: `${search} ${baseFilters} author:@me`.trim(),
3765
- assigned: `${search} ${baseFilters} ${assignedQualifier}`.trim(),
3766
- mentioned: `${search} ${baseFilters} mentions:@me`.trim(),
3767
- authoredCursor: cursor?.authored ?? undefined,
3768
- assignedCursor: cursor?.assigned ?? undefined,
3769
- mentionedCursor: cursor?.mentioned ?? undefined,
3770
- includeAuthored: includeAuthored,
3771
- includeAssigned: includeAssigned,
3772
- includeMentioned: includeMentioned,
3773
- baseUrl: options?.baseUrl,
3774
- avatarSize: options?.avatarSize,
3775
- },
3957
+ variables,
3776
3958
  scope,
3777
3959
  cancellation,
3778
3960
  );
3779
-
3780
3961
  if (rsp == null) return { values: [], hasMore: false, page: page, truncated: false };
3781
3962
 
3782
3963
  // Map node-by-node so one unmappable issue can't discard the whole result set
3783
3964
  const issues: IssueShape[] = [];
3784
- for (const node of [
3785
- ...(rsp.assigned?.nodes ?? []),
3786
- ...(rsp.mentioned?.nodes ?? []),
3787
- ...(rsp.authored?.nodes ?? []),
3788
- ]) {
3789
- if (node?.id == null) continue;
3790
-
3791
- try {
3792
- issues.push(fromGitHubIssue(node, provider));
3793
- } catch (ex) {
3794
- scope?.warn(`skipped unmappable issue; id=${node.id}, url=${node.url}, ex=${ex}`);
3965
+ for (const s of active) {
3966
+ for (const node of rsp[s.alias]?.nodes ?? []) {
3967
+ if (node?.id == null) continue;
3968
+
3969
+ try {
3970
+ issues.push(fromGitHubIssue(node, provider));
3971
+ } catch (ex) {
3972
+ scope?.warn(`skipped unmappable issue; id=${node.id}, url=${node.url}, ex=${ex}`);
3973
+ }
3795
3974
  }
3796
3975
  }
3797
3976
 
3977
+ // Dedupe by `url`, not `IssueShape.id`: for some providers `id` is a per-repository number, so an
3978
+ // id-keyed map would collapse distinct issues across repositories.
3798
3979
  const results: IterableIterator<IssueShape> = uniqueBy(
3799
3980
  issues,
3800
3981
  r => r.url,
3801
3982
  (original, _current) => original,
3802
3983
  );
3803
3984
 
3804
- const nextCategoryCursor = (category: SearchCategory | undefined): string | null => {
3805
- if (category?.pageInfo?.hasNextPage && category.pageInfo.endCursor) {
3806
- return category.pageInfo.endCursor;
3985
+ // Every alias gets a slot, so an inactive one keeps its `null` and stays out of the next request. A
3986
+ // missing slot would be read as "never requested", which for a `searches` set that still lists it
3987
+ // would restart it from its first page.
3988
+ const next: SearchCursor = { page: page + 1 };
3989
+ let hasMore = false;
3990
+ let continuationMissing = false;
3991
+ let maxIssueCount = 0;
3992
+ for (const s of searches) {
3993
+ const category = rsp[s.alias];
3994
+ const endCursor =
3995
+ category?.pageInfo?.hasNextPage && category.pageInfo.endCursor ? category.pageInfo.endCursor : null;
3996
+ next[s.alias] = endCursor;
3997
+ if (endCursor != null) {
3998
+ hasMore = true;
3807
3999
  }
3808
- return null;
3809
- };
3810
- const next: SearchCursor = {
3811
- page: page + 1,
3812
- authored: nextCategoryCursor(rsp.authored),
3813
- assigned: nextCategoryCursor(rsp.assigned),
3814
- mentioned: nextCategoryCursor(rsp.mentioned),
3815
- };
3816
- const hasMore = next.authored != null || next.assigned != null || next.mentioned != null;
3817
- // GitHub search exposes at most 1,000 results per category. Paging removes the previous 100-item
3818
- // truncation; only the upstream search ceiling or an unusable continuation remains incomplete.
3819
- const continuationMissing = [rsp.authored, rsp.assigned, rsp.mentioned].some(
3820
- category => category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor == null,
3821
- );
4000
+ if (category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor == null) {
4001
+ continuationMissing = true;
4002
+ }
4003
+ maxIssueCount = Math.max(maxIssueCount, category?.issueCount ?? 0);
4004
+ }
4005
+
4006
+ // GitHub search exposes at most `githubSearchResultLimit` results PER SEARCH, so the ceiling is
4007
+ // reached as soon as any one alias exceeds it. Paging removes the old 100-item truncation; only that
4008
+ // upstream ceiling or an unusable continuation leaves the read incomplete.
3822
4009
  const truncated =
3823
- cursor?.truncated === true ||
3824
- (rsp.authored?.issueCount ?? 0) > 1000 ||
3825
- (rsp.assigned?.issueCount ?? 0) > 1000 ||
3826
- (rsp.mentioned?.issueCount ?? 0) > 1000 ||
3827
- continuationMissing;
4010
+ cursor?.truncated === true || maxIssueCount > githubSearchResultLimit || continuationMissing;
3828
4011
  next.truncated = truncated || undefined;
3829
4012
  return {
3830
4013
  values: [...results],
@@ -3832,6 +4015,7 @@ export class GitHubApi {
3832
4015
  hasMore: hasMore,
3833
4016
  page: page,
3834
4017
  truncated: truncated,
4018
+ totalCount: maxIssueCount,
3835
4019
  };
3836
4020
  } catch (ex) {
3837
4021
  throw this.handleException(ex, provider, scope);