@gitkraken/core-gitlens 0.5.102 → 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 +26 -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/collectionMetadata.d.ts +15 -1
- package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
- package/dist/plus/integrations/collectionMetadata.js +21 -2
- package/dist/plus/integrations/collectionMetadata.js.map +1 -1
- 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/hierarchy.d.ts.map +1 -1
- package/dist/plus/integrations/reads/hierarchy.js +34 -17
- package/dist/plus/integrations/reads/hierarchy.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 +33 -3
- package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
- package/dist/plus/integrations/reads/warnings.js +87 -6
- 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/collectionMetadata.ts +22 -3
- 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/hierarchy.ts +43 -32
- 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 +129 -11
|
@@ -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 >
|
|
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
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
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
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
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
|
|
3664
|
-
//
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
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
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
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
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
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
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
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
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
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<
|
|
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
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
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
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
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
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
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);
|