@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.
- package/CHANGELOG.md +19 -1
- package/dist/git/models/issue.d.ts +78 -0
- package/dist/git/models/issue.d.ts.map +1 -1
- package/dist/git/models/issue.js.map +1 -1
- package/dist/plus/git-github/api/config.d.ts +12 -0
- package/dist/plus/git-github/api/config.d.ts.map +1 -1
- package/dist/plus/git-github/api/config.js +12 -1
- package/dist/plus/git-github/api/config.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +77 -7
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js +241 -120
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/api/issueSearchQuery.d.ts +70 -0
- package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -0
- package/dist/plus/git-github/api/issueSearchQuery.js +143 -0
- package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -0
- package/dist/plus/integrations/index.d.ts +2 -0
- package/dist/plus/integrations/index.d.ts.map +1 -1
- package/dist/plus/integrations/integrationService.d.ts +39 -2
- package/dist/plus/integrations/integrationService.d.ts.map +1 -1
- package/dist/plus/integrations/integrationService.js +17 -0
- package/dist/plus/integrations/integrationService.js.map +1 -1
- package/dist/plus/integrations/manager.d.ts +88 -2
- package/dist/plus/integrations/manager.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.d.ts +61 -3
- package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.js +51 -0
- package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
- package/dist/plus/integrations/models/integration.d.ts +3 -46
- package/dist/plus/integrations/models/integration.d.ts.map +1 -1
- package/dist/plus/integrations/models/integration.js.map +1 -1
- package/dist/plus/integrations/models/issueReads.d.ts +70 -0
- package/dist/plus/integrations/models/issueReads.d.ts.map +1 -0
- package/dist/plus/integrations/models/issueReads.js +2 -0
- package/dist/plus/integrations/models/issueReads.js.map +1 -0
- package/dist/plus/integrations/providerFilters.d.ts +10 -0
- package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
- package/dist/plus/integrations/providers/github.d.ts +26 -3
- package/dist/plus/integrations/providers/github.d.ts.map +1 -1
- package/dist/plus/integrations/providers/github.js +34 -0
- package/dist/plus/integrations/providers/github.js.map +1 -1
- package/dist/plus/integrations/providers/models.d.ts +22 -0
- package/dist/plus/integrations/providers/models.d.ts.map +1 -1
- package/dist/plus/integrations/providers/models.js +26 -0
- package/dist/plus/integrations/providers/models.js.map +1 -1
- package/dist/plus/integrations/reads/broaden.d.ts +10 -0
- package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
- package/dist/plus/integrations/reads/broaden.js.map +1 -1
- package/dist/plus/integrations/reads/counts.d.ts +64 -0
- package/dist/plus/integrations/reads/counts.d.ts.map +1 -0
- package/dist/plus/integrations/reads/counts.js +164 -0
- package/dist/plus/integrations/reads/counts.js.map +1 -0
- package/dist/plus/integrations/reads/filters.d.ts +63 -1
- package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
- package/dist/plus/integrations/reads/filters.js +125 -0
- package/dist/plus/integrations/reads/filters.js.map +1 -1
- package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
- package/dist/plus/integrations/reads/issues.js +22 -41
- package/dist/plus/integrations/reads/issues.js.map +1 -1
- package/dist/plus/integrations/reads/paging.d.ts +59 -0
- package/dist/plus/integrations/reads/paging.d.ts.map +1 -1
- package/dist/plus/integrations/reads/paging.js +60 -0
- package/dist/plus/integrations/reads/paging.js.map +1 -1
- package/dist/plus/integrations/reads/searchIssues.d.ts +55 -0
- package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -0
- package/dist/plus/integrations/reads/searchIssues.js +144 -0
- package/dist/plus/integrations/reads/searchIssues.js.map +1 -0
- package/dist/plus/integrations/reads/warnings.d.ts +25 -1
- package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
- package/dist/plus/integrations/reads/warnings.js +86 -2
- package/dist/plus/integrations/reads/warnings.js.map +1 -1
- package/docs/integrations.md +110 -2
- package/docs/kepler-read-api-parity.md +57 -0
- package/package.json +1 -1
- package/src/git/models/issue.ts +81 -0
- package/src/plus/git-github/api/config.ts +13 -0
- package/src/plus/git-github/api/github.ts +332 -148
- package/src/plus/git-github/api/issueSearchQuery.ts +165 -0
- package/src/plus/integrations/index.ts +5 -0
- package/src/plus/integrations/integrationService.ts +47 -1
- package/src/plus/integrations/manager.ts +88 -2
- package/src/plus/integrations/models/gitHostIntegration.ts +102 -2
- package/src/plus/integrations/models/integration.ts +7 -46
- package/src/plus/integrations/models/issueReads.ts +73 -0
- package/src/plus/integrations/providerFilters.ts +15 -0
- package/src/plus/integrations/providers/github.ts +64 -2
- package/src/plus/integrations/providers/models.ts +49 -0
- package/src/plus/integrations/reads/broaden.ts +10 -0
- package/src/plus/integrations/reads/counts.ts +285 -0
- package/src/plus/integrations/reads/filters.ts +162 -1
- package/src/plus/integrations/reads/issues.ts +22 -45
- package/src/plus/integrations/reads/paging.ts +102 -0
- package/src/plus/integrations/reads/searchIssues.ts +252 -0
- 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
|
+
}
|