@gitkraken/core-gitlens 0.5.103 → 0.5.104

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 (94) hide show
  1. package/CHANGELOG.md +19 -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/index.d.ts +2 -0
  18. package/dist/plus/integrations/index.d.ts.map +1 -1
  19. package/dist/plus/integrations/integrationService.d.ts +39 -2
  20. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  21. package/dist/plus/integrations/integrationService.js +17 -0
  22. package/dist/plus/integrations/integrationService.js.map +1 -1
  23. package/dist/plus/integrations/manager.d.ts +88 -2
  24. package/dist/plus/integrations/manager.d.ts.map +1 -1
  25. package/dist/plus/integrations/models/gitHostIntegration.d.ts +61 -3
  26. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  27. package/dist/plus/integrations/models/gitHostIntegration.js +51 -0
  28. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  29. package/dist/plus/integrations/models/integration.d.ts +3 -46
  30. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  31. package/dist/plus/integrations/models/integration.js.map +1 -1
  32. package/dist/plus/integrations/models/issueReads.d.ts +70 -0
  33. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -0
  34. package/dist/plus/integrations/models/issueReads.js +2 -0
  35. package/dist/plus/integrations/models/issueReads.js.map +1 -0
  36. package/dist/plus/integrations/providerFilters.d.ts +10 -0
  37. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  38. package/dist/plus/integrations/providers/github.d.ts +26 -3
  39. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  40. package/dist/plus/integrations/providers/github.js +34 -0
  41. package/dist/plus/integrations/providers/github.js.map +1 -1
  42. package/dist/plus/integrations/providers/models.d.ts +22 -0
  43. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  44. package/dist/plus/integrations/providers/models.js +26 -0
  45. package/dist/plus/integrations/providers/models.js.map +1 -1
  46. package/dist/plus/integrations/reads/broaden.d.ts +10 -0
  47. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  48. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  49. package/dist/plus/integrations/reads/counts.d.ts +64 -0
  50. package/dist/plus/integrations/reads/counts.d.ts.map +1 -0
  51. package/dist/plus/integrations/reads/counts.js +164 -0
  52. package/dist/plus/integrations/reads/counts.js.map +1 -0
  53. package/dist/plus/integrations/reads/filters.d.ts +63 -1
  54. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  55. package/dist/plus/integrations/reads/filters.js +125 -0
  56. package/dist/plus/integrations/reads/filters.js.map +1 -1
  57. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  58. package/dist/plus/integrations/reads/issues.js +22 -41
  59. package/dist/plus/integrations/reads/issues.js.map +1 -1
  60. package/dist/plus/integrations/reads/paging.d.ts +59 -0
  61. package/dist/plus/integrations/reads/paging.d.ts.map +1 -1
  62. package/dist/plus/integrations/reads/paging.js +60 -0
  63. package/dist/plus/integrations/reads/paging.js.map +1 -1
  64. package/dist/plus/integrations/reads/searchIssues.d.ts +55 -0
  65. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -0
  66. package/dist/plus/integrations/reads/searchIssues.js +144 -0
  67. package/dist/plus/integrations/reads/searchIssues.js.map +1 -0
  68. package/dist/plus/integrations/reads/warnings.d.ts +25 -1
  69. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  70. package/dist/plus/integrations/reads/warnings.js +86 -2
  71. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  72. package/docs/integrations.md +110 -2
  73. package/docs/kepler-read-api-parity.md +57 -0
  74. package/package.json +1 -1
  75. package/src/git/models/issue.ts +81 -0
  76. package/src/plus/git-github/api/config.ts +13 -0
  77. package/src/plus/git-github/api/github.ts +332 -148
  78. package/src/plus/git-github/api/issueSearchQuery.ts +165 -0
  79. package/src/plus/integrations/index.ts +5 -0
  80. package/src/plus/integrations/integrationService.ts +47 -1
  81. package/src/plus/integrations/manager.ts +88 -2
  82. package/src/plus/integrations/models/gitHostIntegration.ts +102 -2
  83. package/src/plus/integrations/models/integration.ts +7 -46
  84. package/src/plus/integrations/models/issueReads.ts +73 -0
  85. package/src/plus/integrations/providerFilters.ts +15 -0
  86. package/src/plus/integrations/providers/github.ts +64 -2
  87. package/src/plus/integrations/providers/models.ts +49 -0
  88. package/src/plus/integrations/reads/broaden.ts +10 -0
  89. package/src/plus/integrations/reads/counts.ts +285 -0
  90. package/src/plus/integrations/reads/filters.ts +162 -1
  91. package/src/plus/integrations/reads/issues.ts +22 -45
  92. package/src/plus/integrations/reads/paging.ts +102 -0
  93. package/src/plus/integrations/reads/searchIssues.ts +252 -0
  94. package/src/plus/integrations/reads/warnings.ts +114 -4
@@ -10,3 +10,18 @@ export enum IssueFilter {
10
10
  Assignee = 'assignee',
11
11
  Mention = 'mention',
12
12
  }
13
+
14
+ /**
15
+ * The filtered issue search's criteria model, re-exported from the git models so it can be reached from here
16
+ * alongside the two filter enums above.
17
+ *
18
+ * It lives in `@gitkraken/core-gitlens/git` rather than in this package because the provider implementations that translate it
19
+ * to a query — the GitHub API client among them — sit BELOW this package in the dependency graph and can't import
20
+ * from it. {@link IssueSearchRelationship} is a superset of {@link IssueFilter}: it adds the two user-independent
21
+ * relationships (`any-assignee`, `unassigned`) that the user-relative "my issues" filters have no way to name.
22
+ */
23
+ export type {
24
+ IssueSearchCapabilities,
25
+ IssueSearchCriteria,
26
+ IssueSearchRelationship,
27
+ } from '../../git/models/issue.js';
@@ -1,6 +1,6 @@
1
1
  import type { Account, UnidentifiedAuthor } from '../../../git/models/author.js';
2
2
  import type { DefaultBranch } from '../../../git/models/defaultBranch.js';
3
- import type { Issue, IssueShape } from '../../../git/models/issue.js';
3
+ import type { Issue, IssueSearchCriteria, IssueShape } from '../../../git/models/issue.js';
4
4
  import type { IssueOrPullRequest } from '../../../git/models/issueOrPullRequest.js';
5
5
  import type {
6
6
  PullRequest,
@@ -25,7 +25,7 @@ import { IntegrationReadUnavailableError } from '../errors.js';
25
25
  import type { IntegrationConnectionChangeEvent } from '../integrationService.js';
26
26
  import type { SearchMyPullRequestsOptions } from '../models/gitHostIntegration.js';
27
27
  import { GitHostIntegration } from '../models/gitHostIntegration.js';
28
- import type { SearchMyIssuesOptions } from '../models/integration.js';
28
+ import type { ProviderIssueSearchPage, SearchMyIssuesOptions } from '../models/integration.js';
29
29
  import type { GitHubIntegrationIds } from './github/github.utils.js';
30
30
  import { getGitHubPullRequestIdentityFromMaybeUrl } from './github/github.utils.js';
31
31
  import type {
@@ -33,6 +33,7 @@ import type {
33
33
  ProviderHierarchyResult,
34
34
  ProviderOrganization,
35
35
  ProviderPullRequest,
36
+ ProviderRepoInput,
36
37
  ProviderRepository,
37
38
  } from './models.js';
38
39
  import {
@@ -592,6 +593,10 @@ abstract class GitHubIntegrationBase<ID extends GitHubIntegrationIds> extends Gi
592
593
  cancellation?: AbortSignal,
593
594
  options?: SearchMyIssuesOptions,
594
595
  ): Promise<{ values: IssueShape[]; truncated: boolean } | undefined> {
596
+ // `includeAllAssignees` becomes `assignee:*`, which needs a scope to mean anything — with none it matches
597
+ // millions of issues across all of GitHub rather than "assigned to anyone in the user's world". ANY scope
598
+ // works (one repo, several, or an org), so this refuses only the genuinely unscoped read; the scoped
599
+ // "assigned to anyone over these repos" request is served by `searchIssuesPage`.
595
600
  if ((repos == null || repos.length === 0) && options?.includeAllAssignees) {
596
601
  throw new IntegrationReadUnavailableError(
597
602
  this.name,
@@ -620,6 +625,63 @@ abstract class GitHubIntegrationBase<ID extends GitHubIntegrationIds> extends Gi
620
625
  );
621
626
  }
622
627
 
628
+ /**
629
+ * The filtered issue search: one GraphQL request per page, no forced relationship to the current user, and no
630
+ * route through the SDK's repo-scoped read (whose over-limit recovery walk can spend up to 128 requests).
631
+ *
632
+ * The criteria → qualifier translation and the ordering guarantee live in the API client
633
+ * (`GitHubApi.searchIssuesPage`), which is also where user input is sanitized so it can't inject a qualifier.
634
+ */
635
+ protected override async searchProviderIssuesPage(
636
+ session: ProviderAuthenticationSession,
637
+ options: {
638
+ repos?: ProviderRepoInput[];
639
+ org?: string;
640
+ criteria?: IssueSearchCriteria;
641
+ cursor?: string;
642
+ pageSize?: number;
643
+ },
644
+ cancellation?: AbortSignal,
645
+ ): Promise<ProviderIssueSearchPage | undefined> {
646
+ return (await this.authenticationService.apis.github)?.searchIssuesPage(
647
+ this,
648
+ toTokenWithInfo(this.id, session),
649
+ {
650
+ // `namespace` is the owner for GitHub, which is how a `repo:` qualifier names a repository.
651
+ repos: options.repos?.map(r => `${r.namespace}/${r.name}`),
652
+ org: options.org,
653
+ criteria: options.criteria,
654
+ baseUrl: this.apiBaseUrl,
655
+ includeBody: true,
656
+ cursor: options.cursor,
657
+ pageSize: options.pageSize,
658
+ },
659
+ cancellation,
660
+ );
661
+ }
662
+
663
+ /**
664
+ * Counts several scopes in ONE request. GitHub's `search` reports `issueCount` on a zero-node selection, so a
665
+ * count preview costs no issue transfer — measured, 30 aliased counts are a single rate-limit point.
666
+ */
667
+ protected override async countProviderIssues(
668
+ session: ProviderAuthenticationSession,
669
+ scopes: readonly { repos?: ProviderRepoInput[]; org?: string; criteria?: IssueSearchCriteria }[],
670
+ cancellation?: AbortSignal,
671
+ ): Promise<(number | undefined)[] | undefined> {
672
+ return (await this.authenticationService.apis.github)?.countIssues(
673
+ this,
674
+ toTokenWithInfo(this.id, session),
675
+ scopes.map(s => ({
676
+ repos: s.repos?.map(r => `${r.namespace}/${r.name}`),
677
+ org: s.org,
678
+ criteria: s.criteria,
679
+ })),
680
+ { baseUrl: this.apiBaseUrl },
681
+ cancellation,
682
+ );
683
+ }
684
+
623
685
  protected override async searchProviderPullRequests(
624
686
  session: ProviderAuthenticationSession,
625
687
  searchQuery: string,
@@ -43,6 +43,7 @@ import type {
43
43
  } from '@gitkraken/provider-apis';
44
44
  import entityIdentifiersModule from '@gitkraken/provider-apis/entity-identifiers';
45
45
  import providerUtilsModule from '@gitkraken/provider-apis/provider-utils';
46
+ import { githubSearchResultLimit } from '../../git-github/api/config.js';
46
47
  import type { Account as UserAccount } from '../../../git/models/author.js';
47
48
  import type { IssueMember, IssueProject, IssueShape, IssueStateFilter } from '../../../git/models/issue.js';
48
49
  import { Issue, RepositoryAccessLevel } from '../../../git/models/issue.js';
@@ -74,9 +75,11 @@ import {
74
75
  IssuesCloudHostIntegrationId,
75
76
  } from '../constants.js';
76
77
  import type { Integration, IntegrationType } from '../models/integration.js';
78
+ import type { IssueSearchCapabilities } from '../providerFilters.js';
77
79
  import { IssueFilter, PullRequestFilter } from '../providerFilters.js';
78
80
 
79
81
  export { IssueFilter, PullRequestFilter } from '../providerFilters.js';
82
+ export type { IssueSearchCapabilities, IssueSearchCriteria, IssueSearchRelationship } from '../providerFilters.js';
80
83
  import type { ProviderRepositoryShape } from '../results.js';
81
84
 
82
85
  export type { ProviderOrganization, ProviderRepositoryShape } from '../results.js';
@@ -710,11 +713,53 @@ export interface ProviderMetadata {
710
713
  * axes; it omits `Mention` because that REST read has no first-class mention filter.
711
714
  */
712
715
  supportedAccountWideIssueFilters?: IssueFilter[];
716
+ /**
717
+ * What the provider's FILTERED issue search (`searchIssuesPage`, and the `countIssues` probe over the same
718
+ * criteria) can express server-side. A third, wider surface than either filter set above: it is not bound to
719
+ * the user at all, so it takes relationships those reads have no way to name (`any-assignee`, `unassigned`),
720
+ * plus free text and issue attributes.
721
+ *
722
+ * Absent means the provider has NO filtered issue search and the read is refused. Present, it is a promise:
723
+ * every field declared here reaches the provider query, so a consumer that intersects against it never has a
724
+ * read refused, and a criterion is never silently ignored.
725
+ */
726
+ supportedIssueSearch?: IssueSearchCapabilities;
727
+ /**
728
+ * The maximum number of results the provider's filtered issue search will serve for ONE query, however it is
729
+ * paged. Matches past it are UNREACHABLE, not merely unfetched, which is why a read that hits this reports an
730
+ * omission with `recovery: 'none'` and the total match count rather than offering a "load more".
731
+ *
732
+ * Absent means the provider declares no ceiling, in which case a truncated read falls back to generic wording
733
+ * instead of quoting a limit that was never published.
734
+ */
735
+ issueSearchResultLimit?: number;
713
736
  }
714
737
 
715
738
  export type Providers = Record<IntegrationIds, ProviderInfo>;
716
739
  export type ProvidersMetadata = Record<IntegrationIds, ProviderMetadata>;
717
740
 
741
+ /**
742
+ * GitHub and GitHub Enterprise express every criterion as a search qualifier on the same GraphQL `search` field,
743
+ * so the two share one declaration — a GHE instance running the same search syntax has the same capability, and
744
+ * two copies of this table would be free to drift.
745
+ *
746
+ * Every entry is a claim the implementation must keep: `searchIssuesPage` emits a qualifier for each of these,
747
+ * and a test asserts that (see the `issueSearch` capability tests) so the table can't quietly outrun the code.
748
+ */
749
+ const githubIssueSearchCapabilities: IssueSearchCapabilities = {
750
+ // `author:@me` / `assignee:@me` / `mentions:@me` / `assignee:*` / `no:assignee`, each its own aliased search.
751
+ relationships: ['authored', 'assigned', 'mentioned', 'any-assignee', 'unassigned'],
752
+ text: true,
753
+ labels: true,
754
+ milestone: true,
755
+ updatedAfter: true,
756
+ createdAfter: true,
757
+ // `-linked:pr`.
758
+ withoutLinkedPullRequest: true,
759
+ // `is:open` / `is:closed`, or neither for all states.
760
+ states: true,
761
+ };
762
+
718
763
  export const providersMetadata: ProvidersMetadata = {
719
764
  [GitCloudHostIntegrationId.GitHub]: {
720
765
  domain: 'github.com',
@@ -742,6 +787,8 @@ export const providersMetadata: ProvidersMetadata = {
742
787
  // The account-wide read is three independent searches (`author:@me`, `assignee:@me`, `mentions:@me`) behind
743
788
  // one composite cursor, so any subset of them is expressible.
744
789
  supportedAccountWideIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention],
790
+ supportedIssueSearch: githubIssueSearchCapabilities,
791
+ issueSearchResultLimit: githubSearchResultLimit,
745
792
  scopes: ['repo', 'read:user', 'user:email'],
746
793
  },
747
794
  [GitSelfManagedHostIntegrationId.CloudGitHubEnterprise]: {
@@ -770,6 +817,8 @@ export const providersMetadata: ProvidersMetadata = {
770
817
  // The account-wide read is three independent searches (`author:@me`, `assignee:@me`, `mentions:@me`) behind
771
818
  // one composite cursor, so any subset of them is expressible.
772
819
  supportedAccountWideIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention],
820
+ supportedIssueSearch: githubIssueSearchCapabilities,
821
+ issueSearchResultLimit: githubSearchResultLimit,
773
822
  scopes: ['repo', 'read:user', 'user:email'],
774
823
  },
775
824
  [GitCloudHostIntegrationId.GitLab]: {
@@ -23,6 +23,16 @@ import { gitHostOnlySurfaceWarning, issuesUnsupportedWarning, noConnectionWarnin
23
23
  * provider positions — one per org — so its continuation is a per-org cursor BUNDLE (see `cursors.ts`) and its
24
24
  * failure attribution is per provider across those orgs. Both are the reason a page number alone can't address a
25
25
  * later page, and why {@link broadenIssues} walks prior pages itself when given only `page`.
26
+ *
27
+ * SUPERSEDED for a caller that already knows its repositories: `searchIssuesPage({ repos, criteria })` answers
28
+ * the same question in one request per page, with no repository drain and no route through the SDK read whose
29
+ * over-limit recovery walk can spend up to 128 requests. This read stays for "fan out across these orgs,
30
+ * whatever repos they contain", whose per-provider attribution the single-provider search doesn't produce.
31
+ *
32
+ * If you migrate a caller, note the semantics carefully: broadening means ALL VISIBLE — it passes
33
+ * `includeAllAssignees: true`, which resolves to no assignee constraint at all, so unassigned issues ARE
34
+ * included. The equivalent is an OMITTED `relationships`, not `['any-assignee']`: `assignee:*` means "has some
35
+ * assignee" and would silently exclude every unassigned issue, which is the opposite of broadening.
26
36
  */
27
37
 
28
38
  export interface BroadenIssuesOptions {
@@ -0,0 +1,285 @@
1
+ import type { IssueSearchCriteria } from '../../../git/models/issue.js';
2
+ import { chunk } from '../../../utils/array.js';
3
+ import { mapBounded } from '../../../utils/promise.js';
4
+ import type { IntegrationIds } from '../constants.js';
5
+ import { providerFanOutConcurrency } from '../constants.js';
6
+ import type { ProviderRepoInput, ProviderReposInput } from '../providers/models.js';
7
+ import { providersMetadata } from '../providers/models.js';
8
+ import type { ProviderResult, ProviderWarning } from '../results.js';
9
+ import { appendDedupedWarning } from '../results.js';
10
+ import {
11
+ isGitHostIntegration,
12
+ isIssuesHostIntegrationId,
13
+ warnOnMissingSessionForDomain,
14
+ } from '../utils/integration.utils.js';
15
+ import type { ProviderReadContext } from './context.js';
16
+ import { runCaptured } from './drains.js';
17
+ import { resolveIssueSearchCriteria, resolveIssueSearchScope } from './filters.js';
18
+ import {
19
+ gitHostOnlySurfaceWarning,
20
+ issuesUnsupportedWarning,
21
+ otherWarning,
22
+ unsupportedIssueSearchCriteriaWarning,
23
+ } from './warnings.js';
24
+
25
+ /**
26
+ * How many scopes go into one upstream request.
27
+ *
28
+ * Measured against the live GitHub API, 30 aliased counts cost a single rate-limit point and ~2s, and 60 cost the
29
+ * same point and roughly twice as long — so the constraint isn't rate limit, it's latency and the provider's
30
+ * (uncontracted, so not to be leaned on) query-complexity limits. 25 keeps a chunk comfortably inside both while
31
+ * still collapsing a realistic filter matrix into one or two requests.
32
+ */
33
+ const issueCountChunkSize = 25;
34
+
35
+ /**
36
+ * One scope to count.
37
+ *
38
+ * `key` is caller-owned and echoed back verbatim, so a batch result needs no positional matching by the consumer.
39
+ * It never reaches the provider query — the aliases in the upstream document are generated — so it can be any
40
+ * string the caller finds meaningful.
41
+ */
42
+ export interface IssueCountScope {
43
+ key: string;
44
+ /** Repositories to count over. Combines with `org`, exactly as in `searchIssuesPage`. */
45
+ repos?: ProviderReposInput;
46
+ org?: string;
47
+ /** Same criteria model as `searchIssuesPage`, validated against the same capability table. */
48
+ criteria?: IssueSearchCriteria;
49
+ }
50
+
51
+ /** The count for one {@link IssueCountScope}, echoed back under the caller's own `key`. */
52
+ export interface IssueCountResult {
53
+ key: string;
54
+ /**
55
+ * Total matches the provider reports. `undefined` when the provider didn't report one for this scope — NEVER
56
+ * zero, which is a real answer. Render the difference: an unreported count means "unknown", and showing it as
57
+ * 0 would tell the user this filter matches nothing.
58
+ */
59
+ count?: number;
60
+ /**
61
+ * True when `count` exceeds the provider's own result ceiling, so a full read CANNOT return everything no
62
+ * matter how it is paged. This is the signal to warn before starting an expensive fetch.
63
+ */
64
+ exceedsProviderLimit: boolean;
65
+ /** The ceiling itself, when the provider declares one. */
66
+ providerLimit?: number;
67
+ }
68
+
69
+ /**
70
+ * Counts issues for many scopes without fetching any — the probe behind a "this will fetch ~N issues" preview.
71
+ *
72
+ * A distinct method rather than a `countOnly` flag on `searchIssuesPage`, because transferring ZERO issues is the
73
+ * entire value: a flag would return a paged result whose `items`, `cursor` and `hasMore` are all meaningless, and
74
+ * every consumer would have to know which fields to ignore. A separate method has a return type that only
75
+ * describes counts.
76
+ *
77
+ * Scopes are batched into as few upstream requests as possible ({@link issueCountChunkSize} each). A chunk that
78
+ * fails warns and drops only its own scopes, leaving the successful chunks' counts intact — so a partial answer is
79
+ * still useful — with `fetchFailed` set. A scope refused for its own reasons (unscoped, inexpressible criteria) is
80
+ * likewise isolated: its siblings are still counted.
81
+ *
82
+ * Cheap is not free: each chunk is a network request, so a caller driving this from UI state is expected to
83
+ * debounce and cache.
84
+ */
85
+ export async function countIssues(
86
+ ctx: ProviderReadContext,
87
+ options: {
88
+ providerId: IntegrationIds;
89
+ scopes: readonly IssueCountScope[];
90
+ connectionId?: string;
91
+ /**
92
+ * Explicit self-managed host domain. Used only when the requested connection has no configured domain;
93
+ * it must come from the trusted authentication configuration, not repository or remote data.
94
+ */
95
+ domain?: string;
96
+ },
97
+ ): Promise<ProviderResult<IssueCountResult>> {
98
+ const refused = (warning: ProviderWarning): ProviderResult<IssueCountResult> => ({
99
+ items: [],
100
+ warnings: [warning],
101
+ fetchFailed: true,
102
+ });
103
+
104
+ if (isIssuesHostIntegrationId(options.providerId)) {
105
+ return refused(gitHostOnlySurfaceWarning(options.providerId, undefined, options.connectionId, 'Issue counts'));
106
+ }
107
+
108
+ // Nothing was asked for, so nothing is missing: an empty success, not a refusal.
109
+ if (options.scopes.length === 0) return { items: [], warnings: [] };
110
+
111
+ const duplicateKey = findDuplicateKey(options.scopes);
112
+ if (duplicateKey != null) {
113
+ // Refuses the whole call rather than deduping: `key` exists so the caller can match results without
114
+ // positional bookkeeping, and two results under one key make that ambiguous for EVERY scope, not just the
115
+ // repeated one.
116
+ return refused(
117
+ otherWarning(
118
+ options.providerId,
119
+ undefined,
120
+ options.connectionId,
121
+ `Duplicate issue count scope key '${duplicateKey}'; keys identify results, so each must be unique.`,
122
+ ),
123
+ );
124
+ }
125
+
126
+ const integration = await ctx.getIntegrationForRead(options.providerId, options.connectionId, options.domain);
127
+ if (integration == null) {
128
+ // A supplied connection or domain that no longer resolves is a broken target, not an empty account.
129
+ const early = ctx.earlyReturnConnectionWarnings(options.providerId, options.connectionId, options.domain);
130
+ return { items: [], warnings: early.warnings, fetchFailed: early.fetchFailed || undefined };
131
+ }
132
+ if (!isGitHostIntegration(integration)) {
133
+ return refused(gitHostOnlySurfaceWarning(options.providerId, undefined, options.connectionId, 'Issue counts'));
134
+ }
135
+
136
+ const domain = ctx.domainForRead(integration, options.providerId, options.connectionId, options.domain);
137
+ const warnOnMissingSession = warnOnMissingSessionForDomain(options.providerId, options.domain);
138
+
139
+ if (!integration.supportsIssues) {
140
+ return refused(issuesUnsupportedWarning(options.providerId, domain, options.connectionId));
141
+ }
142
+
143
+ const providerLimit = providersMetadata[options.providerId]?.issueSearchResultLimit;
144
+ const warnings: ProviderWarning[] = [];
145
+ let fetchFailed = false;
146
+
147
+ // Validate every scope first, so a refusal costs no request at all and the countable ones are still batched
148
+ // together.
149
+ const countable: IssueCountScope[] = [];
150
+ for (const scope of options.scopes) {
151
+ const warning = rejectScope(options.providerId, domain, options.connectionId, scope);
152
+ if (warning != null) {
153
+ // `push`, not `appendDedupedWarning`: every rejection message embeds the scope's own key, and duplicate
154
+ // keys were already refused above, so no two of these can ever collapse — deduping them would only pay
155
+ // the O(n²) key comparison to prove it.
156
+ warnings.push(warning);
157
+ fetchFailed = true;
158
+ continue;
159
+ }
160
+
161
+ countable.push(scope);
162
+ }
163
+
164
+ // Chunks are independent requests over their own slice of scopes — nothing in one reads what another produced,
165
+ // and `runCaptured` never throws — so they run concurrently, bounded like every other fan-out on the facade.
166
+ // Sequentially they would spend exactly the resource this probe is tuned to conserve: latency, ~2s per chunk.
167
+ // `mapBounded` returns in input order, so `items` and `warnings` stay in scope order.
168
+ const batches = await mapBounded(chunk(countable, issueCountChunkSize), providerFanOutConcurrency, batch =>
169
+ runCaptured(
170
+ options.providerId,
171
+ domain,
172
+ options.connectionId,
173
+ () =>
174
+ integration.countIssuesResult(
175
+ batch.map(s => ({
176
+ repos: s.repos as ProviderRepoInput[] | undefined,
177
+ org: s.org,
178
+ criteria: s.criteria,
179
+ })),
180
+ undefined,
181
+ options.connectionId,
182
+ ),
183
+ { warnOnMissingSession: warnOnMissingSession },
184
+ ).then(result => ({ batch: batch, ...result })),
185
+ );
186
+
187
+ const items: IssueCountResult[] = [];
188
+ for (const { batch, value, warning } of batches) {
189
+ if (warning != null) {
190
+ appendDedupedWarning(warnings, warning);
191
+ }
192
+ if (value == null) {
193
+ // This batch contributes nothing, but the batches around it still do. Drop only these scopes.
194
+ fetchFailed = true;
195
+ continue;
196
+ }
197
+
198
+ for (let i = 0; i < batch.length; i++) {
199
+ const count = value[i];
200
+ items.push({
201
+ key: batch[i].key,
202
+ count: count,
203
+ // Only a reported count can exceed a declared ceiling; unknown-vs-limit is not a comparison.
204
+ exceedsProviderLimit: count != null && providerLimit != null && count > providerLimit,
205
+ providerLimit: providerLimit,
206
+ });
207
+ }
208
+ }
209
+
210
+ // A provider with no count support returns `undefined` with no error, which lands as an empty `items` and no
211
+ // warning. Say so explicitly rather than letting it read as "every scope matched nothing".
212
+ if (items.length === 0 && warnings.length === 0) {
213
+ return refused(
214
+ unsupportedIssueSearchCriteriaWarning(options.providerId, domain, options.connectionId, {
215
+ reason: 'unsupported-search',
216
+ }),
217
+ );
218
+ }
219
+
220
+ return { items: items, warnings: warnings, fetchFailed: fetchFailed || undefined };
221
+ }
222
+
223
+ /** The first key that appears twice, or `undefined` when every key is unique. */
224
+ function findDuplicateKey(scopes: readonly IssueCountScope[]): string | undefined {
225
+ const seen = new Set<string>();
226
+ for (const scope of scopes) {
227
+ if (seen.has(scope.key)) return scope.key;
228
+
229
+ seen.add(scope.key);
230
+ }
231
+ return undefined;
232
+ }
233
+
234
+ /**
235
+ * Why one scope can't be counted, as the warning to report — or `undefined` when it can.
236
+ *
237
+ * The scope and criteria rules come from {@link resolveIssueSearchScope} / {@link resolveIssueSearchCriteria},
238
+ * the same validators `searchIssuesPage` uses, so a count always previews the constraints the read would apply.
239
+ * That is not cosmetic: a count computed under different constraints is a WRONG number rather than a missing
240
+ * one, which is worse. Only the wording is local, because these name the offending scope's key.
241
+ */
242
+ function rejectScope(
243
+ providerId: IntegrationIds,
244
+ domain: string | undefined,
245
+ connectionId: string | undefined,
246
+ scope: IssueCountScope,
247
+ ): ProviderWarning | undefined {
248
+ const scoping = resolveIssueSearchScope(scope.repos, scope.org, scope.criteria);
249
+ switch (scoping.rejection) {
250
+ case 'repo-ids':
251
+ return otherWarning(
252
+ providerId,
253
+ domain,
254
+ connectionId,
255
+ `Issue count scope '${scope.key}' cannot be scoped by repository id; pass repository descriptors (namespace + name) instead.`,
256
+ );
257
+ case 'unscoped':
258
+ return otherWarning(
259
+ providerId,
260
+ domain,
261
+ connectionId,
262
+ `Issue count scope '${scope.key}' is unscoped; pass \`repos\`, \`org\`, or a relationship to the current user. \`any-assignee\` and \`unassigned\` are not scopes.`,
263
+ );
264
+ }
265
+
266
+ const resolved = resolveIssueSearchCriteria(providerId, scope.criteria);
267
+ if (resolved.rejection != null) {
268
+ return unsupportedIssueSearchCriteriaWarning(providerId, domain, connectionId, resolved.rejection);
269
+ }
270
+
271
+ // Count-only, with no counterpart in the read: a relationship set is an OR across several searches, which one
272
+ // count can't express — summing them would double-count anything matching two, and taking the max would
273
+ // under-report. Rather than answer with a wrong number, ask the caller to count each relationship as its own
274
+ // scope, where the keys make the OR explicit.
275
+ if ((scope.criteria?.relationships?.length ?? 0) > 1) {
276
+ return otherWarning(
277
+ providerId,
278
+ domain,
279
+ connectionId,
280
+ `Issue count scope '${scope.key}' requests several relationships, which a single count can't express (they are OR-ed, so overlapping matches would be double-counted); pass one scope per relationship.`,
281
+ );
282
+ }
283
+
284
+ return undefined;
285
+ }