@gitkraken/core-gitlens 0.5.108 → 0.5.110
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 +49 -1
- package/dist/git/models/issue.d.ts +70 -0
- package/dist/git/models/issue.d.ts.map +1 -1
- package/dist/git/models/issue.js +17 -0
- package/dist/git/models/issue.js.map +1 -1
- package/dist/git/models/pullRequest.d.ts +81 -0
- package/dist/git/models/pullRequest.d.ts.map +1 -1
- package/dist/git/models/pullRequest.js +15 -0
- package/dist/git/models/pullRequest.js.map +1 -1
- package/dist/git/utils/issue.utils.d.ts +41 -1
- package/dist/git/utils/issue.utils.d.ts.map +1 -1
- package/dist/git/utils/issue.utils.js +80 -0
- package/dist/git/utils/issue.utils.js.map +1 -1
- package/dist/git/utils/pullRequest.utils.d.ts +11 -1
- package/dist/git/utils/pullRequest.utils.d.ts.map +1 -1
- package/dist/git/utils/pullRequest.utils.js +40 -0
- package/dist/git/utils/pullRequest.utils.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +70 -10
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js +284 -31
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/api/issueSearchQuery.d.ts +49 -5
- package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -1
- package/dist/plus/git-github/api/issueSearchQuery.js +64 -6
- package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -1
- package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts +27 -1
- package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts.map +1 -1
- package/dist/plus/git-github/api/pullRequestSearchQuery.js +73 -6
- package/dist/plus/git-github/api/pullRequestSearchQuery.js.map +1 -1
- package/dist/plus/git-github/models.d.ts +17 -5
- package/dist/plus/git-github/models.d.ts.map +1 -1
- package/dist/plus/git-github/models.js +14 -1
- package/dist/plus/git-github/models.js.map +1 -1
- package/dist/plus/integrations/collectionMetadata.d.ts +18 -0
- package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
- package/dist/plus/integrations/collectionMetadata.js +24 -0
- package/dist/plus/integrations/collectionMetadata.js.map +1 -1
- package/dist/plus/integrations/integrationService.d.ts +31 -10
- package/dist/plus/integrations/integrationService.d.ts.map +1 -1
- package/dist/plus/integrations/integrationService.js +9 -1
- package/dist/plus/integrations/integrationService.js.map +1 -1
- package/dist/plus/integrations/manager.d.ts +85 -31
- package/dist/plus/integrations/manager.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.d.ts +18 -31
- package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.js +29 -1
- package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
- package/dist/plus/integrations/models/integration.d.ts +1 -1
- 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 +63 -1
- package/dist/plus/integrations/models/issueReads.d.ts.map +1 -1
- package/dist/plus/integrations/models/issuesIntegration.d.ts +7 -21
- package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
- package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
- package/dist/plus/integrations/providerFilters.d.ts +2 -2
- package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
- package/dist/plus/integrations/providers/accounts.d.ts +6 -0
- package/dist/plus/integrations/providers/accounts.d.ts.map +1 -0
- package/dist/plus/integrations/providers/accounts.js +30 -0
- package/dist/plus/integrations/providers/accounts.js.map +1 -0
- package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
- package/dist/plus/integrations/providers/azureDevOps.js +1 -0
- package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
- package/dist/plus/integrations/providers/github.d.ts +9 -0
- package/dist/plus/integrations/providers/github.d.ts.map +1 -1
- package/dist/plus/integrations/providers/github.js +115 -135
- package/dist/plus/integrations/providers/github.js.map +1 -1
- package/dist/plus/integrations/providers/gitlab.d.ts +9 -0
- package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
- package/dist/plus/integrations/providers/gitlab.js +24 -4
- package/dist/plus/integrations/providers/gitlab.js.map +1 -1
- package/dist/plus/integrations/providers/issueSorts.d.ts +36 -0
- package/dist/plus/integrations/providers/issueSorts.d.ts.map +1 -0
- package/dist/plus/integrations/providers/issueSorts.js +76 -0
- package/dist/plus/integrations/providers/issueSorts.js.map +1 -0
- package/dist/plus/integrations/providers/jira.d.ts +3 -9
- package/dist/plus/integrations/providers/jira.d.ts.map +1 -1
- package/dist/plus/integrations/providers/jira.js +5 -1
- package/dist/plus/integrations/providers/jira.js.map +1 -1
- package/dist/plus/integrations/providers/linear.d.ts +24 -9
- package/dist/plus/integrations/providers/linear.d.ts.map +1 -1
- package/dist/plus/integrations/providers/linear.js +63 -5
- package/dist/plus/integrations/providers/linear.js.map +1 -1
- package/dist/plus/integrations/providers/models.d.ts +76 -38
- package/dist/plus/integrations/providers/models.d.ts.map +1 -1
- package/dist/plus/integrations/providers/models.js +91 -105
- package/dist/plus/integrations/providers/models.js.map +1 -1
- package/dist/plus/integrations/providers/providersApi.d.ts +18 -3
- package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
- package/dist/plus/integrations/providers/providersApi.js +24 -7
- package/dist/plus/integrations/providers/providersApi.js.map +1 -1
- package/dist/plus/integrations/providers/pullRequestReviews.d.ts +50 -0
- package/dist/plus/integrations/providers/pullRequestReviews.d.ts.map +1 -0
- package/dist/plus/integrations/providers/pullRequestReviews.js +99 -0
- package/dist/plus/integrations/providers/pullRequestReviews.js.map +1 -0
- package/dist/plus/integrations/providers/trello.d.ts +3 -9
- package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
- package/dist/plus/integrations/providers/trello.js +1 -0
- package/dist/plus/integrations/providers/trello.js.map +1 -1
- package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
- package/dist/plus/integrations/providers/utils/providerPaging.js +5 -1
- package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
- package/dist/plus/integrations/reads/broaden.d.ts +6 -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 +63 -1
- package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
- package/dist/plus/integrations/reads/counts.js +146 -2
- package/dist/plus/integrations/reads/counts.js.map +1 -1
- package/dist/plus/integrations/reads/drains.d.ts +7 -1
- package/dist/plus/integrations/reads/drains.d.ts.map +1 -1
- package/dist/plus/integrations/reads/drains.js +13 -2
- package/dist/plus/integrations/reads/drains.js.map +1 -1
- package/dist/plus/integrations/reads/filters.d.ts +59 -9
- package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
- package/dist/plus/integrations/reads/filters.js +39 -0
- package/dist/plus/integrations/reads/filters.js.map +1 -1
- package/dist/plus/integrations/reads/issueTracker.d.ts +18 -1
- package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
- package/dist/plus/integrations/reads/issueTracker.js +40 -3
- package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
- package/dist/plus/integrations/reads/issues.d.ts +27 -3
- package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
- package/dist/plus/integrations/reads/issues.js +223 -123
- package/dist/plus/integrations/reads/issues.js.map +1 -1
- package/dist/plus/integrations/reads/ordering.d.ts +101 -0
- package/dist/plus/integrations/reads/ordering.d.ts.map +1 -0
- package/dist/plus/integrations/reads/ordering.js +48 -0
- package/dist/plus/integrations/reads/ordering.js.map +1 -0
- package/dist/plus/integrations/reads/searchIssues.d.ts +7 -2
- package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
- package/dist/plus/integrations/reads/searchIssues.js +9 -3
- package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
- package/dist/plus/integrations/reads/searchPullRequests.d.ts +4 -3
- package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
- package/dist/plus/integrations/reads/searchPullRequests.js +6 -4
- package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
- package/dist/plus/integrations/reads/sweeps.js +1 -1
- package/dist/plus/integrations/reads/sweeps.js.map +1 -1
- package/dist/plus/integrations/reads/warnings.d.ts +35 -3
- package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
- package/dist/plus/integrations/reads/warnings.js +58 -4
- package/dist/plus/integrations/reads/warnings.js.map +1 -1
- package/dist/plus/integrations/results.d.ts +15 -0
- package/dist/plus/integrations/results.d.ts.map +1 -1
- package/dist/plus/integrations/results.js.map +1 -1
- package/docs/integrations.md +76 -10
- package/docs/kepler-read-api-parity.md +9 -0
- package/package.json +2 -2
- package/src/git/models/issue.ts +73 -0
- package/src/git/models/pullRequest.ts +84 -0
- package/src/git/utils/issue.utils.ts +91 -1
- package/src/git/utils/pullRequest.utils.ts +50 -0
- package/src/plus/git-github/api/github.ts +356 -36
- package/src/plus/git-github/api/issueSearchQuery.ts +67 -7
- package/src/plus/git-github/api/pullRequestSearchQuery.ts +81 -7
- package/src/plus/git-github/models.ts +29 -5
- package/src/plus/integrations/collectionMetadata.ts +24 -0
- package/src/plus/integrations/integrationService.ts +40 -12
- package/src/plus/integrations/manager.ts +89 -35
- package/src/plus/integrations/models/gitHostIntegration.ts +47 -31
- package/src/plus/integrations/models/integration.ts +1 -0
- package/src/plus/integrations/models/issueReads.ts +65 -1
- package/src/plus/integrations/models/issuesIntegration.ts +7 -6
- package/src/plus/integrations/providerFilters.ts +8 -1
- package/src/plus/integrations/providers/accounts.ts +34 -0
- package/src/plus/integrations/providers/azureDevOps.ts +1 -0
- package/src/plus/integrations/providers/github.ts +134 -144
- package/src/plus/integrations/providers/gitlab.ts +29 -10
- package/src/plus/integrations/providers/issueSorts.ts +89 -0
- package/src/plus/integrations/providers/jira.ts +9 -3
- package/src/plus/integrations/providers/linear.ts +77 -8
- package/src/plus/integrations/providers/models.ts +185 -128
- package/src/plus/integrations/providers/providersApi.ts +51 -13
- package/src/plus/integrations/providers/pullRequestReviews.ts +122 -0
- package/src/plus/integrations/providers/trello.ts +4 -3
- package/src/plus/integrations/providers/utils/providerPaging.ts +6 -1
- package/src/plus/integrations/reads/broaden.ts +6 -0
- package/src/plus/integrations/reads/counts.ts +281 -3
- package/src/plus/integrations/reads/drains.ts +12 -2
- package/src/plus/integrations/reads/filters.ts +92 -8
- package/src/plus/integrations/reads/issueTracker.ts +78 -4
- package/src/plus/integrations/reads/issues.ts +386 -194
- package/src/plus/integrations/reads/ordering.ts +124 -0
- package/src/plus/integrations/reads/searchIssues.ts +15 -3
- package/src/plus/integrations/reads/searchPullRequests.ts +12 -4
- package/src/plus/integrations/reads/sweeps.ts +1 -1
- package/src/plus/integrations/reads/warnings.ts +84 -2
- package/src/plus/integrations/results.ts +15 -0
|
@@ -11,7 +11,8 @@ 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, IssueSearchCriteria, IssueShape } from '../../../git/models/issue.js';
|
|
14
|
+
import type { Issue, IssueSearchCriteria, IssueShape, IssueSorting } from '../../../git/models/issue.js';
|
|
15
|
+
import { defaultIssueSort } from '../../../git/models/issue.js';
|
|
15
16
|
import type { IssueOrPullRequest } from '../../../git/models/issueOrPullRequest.js';
|
|
16
17
|
import type {
|
|
17
18
|
PullRequest,
|
|
@@ -20,13 +21,15 @@ import type {
|
|
|
20
21
|
PullRequestState,
|
|
21
22
|
PullRequestStateFilter,
|
|
22
23
|
} from '../../../git/models/pullRequest.js';
|
|
23
|
-
import { PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
|
|
24
|
+
import { defaultPullRequestSort, PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
|
|
24
25
|
import type { Provider } from '../../../git/models/remoteProvider.js';
|
|
25
26
|
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata.js';
|
|
26
27
|
import type { GitRevisionRange } from '../../../git/models/revision.js';
|
|
27
28
|
import type { GitUser } from '../../../git/models/user.js';
|
|
28
29
|
import type { RepositoryVisibility } from '../../../git/providers/types.js';
|
|
29
30
|
import { getGitHubNoReplyAddressParts } from '../../../git/remotes/github.js';
|
|
31
|
+
import { effectiveIssueSort, getIssueComparator } from '../../../git/utils/issue.utils.js';
|
|
32
|
+
import { getPullRequestComparator } from '../../../git/utils/pullRequest.utils.js';
|
|
30
33
|
import {
|
|
31
34
|
createRevisionRange,
|
|
32
35
|
getRevisionRangeParts,
|
|
@@ -77,11 +80,21 @@ import {
|
|
|
77
80
|
gitHubIssueSearchRelationships,
|
|
78
81
|
toGitHubIssueSearchQualifiers,
|
|
79
82
|
toGitHubIssueSearchScopeQualifiers,
|
|
83
|
+
toGitHubIssueSortQualifier,
|
|
80
84
|
} from './issueSearchQuery.js';
|
|
81
85
|
import { toGitHubPullRequestSearchFacets } from './pullRequestSearchQuery.js';
|
|
82
86
|
import type { GitHubTokenInfo } from './token.js';
|
|
83
87
|
|
|
84
88
|
const emptyPagedResult: PagedResult<any> = Object.freeze({ values: [] });
|
|
89
|
+
/**
|
|
90
|
+
* What an issue-search cursor records when the caller asked for no ordering at all.
|
|
91
|
+
*
|
|
92
|
+
* A sentinel rather than an omitted field, because omitted already means something else and more important: a
|
|
93
|
+
* cursor persisted before ordering existed. Distinguishing the two is what lets an old cursor keep resuming while
|
|
94
|
+
* a genuine change from unordered to ordered is still refused. Not an `IssueSorting`, so it can never collide
|
|
95
|
+
* with one.
|
|
96
|
+
*/
|
|
97
|
+
const unsortedCursorSort = 'unsorted' as const;
|
|
85
98
|
const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] });
|
|
86
99
|
|
|
87
100
|
// Transient gateway/network failures (e.g. an upstream `502 Bad Gateway`) are worth a few quick
|
|
@@ -98,10 +111,17 @@ const accountResolveBatchSize = 25;
|
|
|
98
111
|
|
|
99
112
|
// Pull-request search selects the full PR fragment (reviews, requests, refs, commits, etc.),
|
|
100
113
|
// which makes GitHub reject broad 100-node searches with `Resource limits for this query
|
|
101
|
-
// exceeded` on large repositories. Thirty keeps the default within that GraphQL cost budget
|
|
102
|
-
//
|
|
114
|
+
// exceeded` on large repositories. Thirty keeps the default within that GraphQL cost budget;
|
|
115
|
+
// the 100-node maximum the connection accepts is for the lite shape, whose per-node cost is a
|
|
116
|
+
// fraction of it. The budget is per DOCUMENT, and one document holds every relationship × state
|
|
117
|
+
// facet, so the worst case scales with the facet count (five relationships × four states = 20).
|
|
103
118
|
const defaultPullRequestSearchPageSize = 30;
|
|
104
119
|
const maxPullRequestSearchPageSize = 100;
|
|
120
|
+
// Pages `searchMyPullRequests` drains for a caller that wants a whole list rather than a page.
|
|
121
|
+
// Four reduced pages cover more than the single 100-node page this read served before it paged,
|
|
122
|
+
// so the smaller page costs coverage nowhere; it bounds an unbounded drain on a read whose
|
|
123
|
+
// consumers (Launchpad, the Graph pull-request panel) block on it.
|
|
124
|
+
const maxMyPullRequestSearchPages = 4;
|
|
105
125
|
|
|
106
126
|
function isRetryableTransientError(ex: unknown): ex is RequestError {
|
|
107
127
|
// An aborted request is rethrown as the original `AbortError` (not a `RequestError`), so it is
|
|
@@ -247,6 +267,24 @@ repository {
|
|
|
247
267
|
viewerPermission
|
|
248
268
|
}
|
|
249
269
|
`;
|
|
270
|
+
/**
|
|
271
|
+
* One review's fields, shared by `latestReviews` and `viewerLatestReview`. The two selections must stay
|
|
272
|
+
* identical: `fromGitHubPullRequest` merges the viewer's review into the capped `latestReviews` window and
|
|
273
|
+
* dedups by `id`, so a field present in only one of them would produce rows that differ by where they came
|
|
274
|
+
* from rather than by what they are.
|
|
275
|
+
*/
|
|
276
|
+
const gqlPullRequestReviewFragment = `
|
|
277
|
+
id
|
|
278
|
+
author {
|
|
279
|
+
login
|
|
280
|
+
avatarUrl(size: $avatarSize)
|
|
281
|
+
url
|
|
282
|
+
}
|
|
283
|
+
state
|
|
284
|
+
commit {
|
|
285
|
+
oid
|
|
286
|
+
}
|
|
287
|
+
`;
|
|
250
288
|
const gqlPullRequestFragment = `
|
|
251
289
|
${gqlPullRequestLiteFragment}
|
|
252
290
|
additions
|
|
@@ -266,14 +304,12 @@ mergedBy {
|
|
|
266
304
|
reviewDecision
|
|
267
305
|
latestReviews(first: 25) {
|
|
268
306
|
nodes {
|
|
269
|
-
|
|
270
|
-
login
|
|
271
|
-
avatarUrl(size: $avatarSize)
|
|
272
|
-
url
|
|
273
|
-
}
|
|
274
|
-
state
|
|
307
|
+
${gqlPullRequestReviewFragment}
|
|
275
308
|
}
|
|
276
309
|
}
|
|
310
|
+
viewerLatestReview {
|
|
311
|
+
${gqlPullRequestReviewFragment}
|
|
312
|
+
}
|
|
277
313
|
reviewRequests(first: 25) {
|
|
278
314
|
nodes {
|
|
279
315
|
asCodeOwner
|
|
@@ -3514,8 +3550,10 @@ export class GitHubApi {
|
|
|
3514
3550
|
* One page of the current user's pull requests, filtered by state and optionally by an explicit
|
|
3515
3551
|
* relationship qualifier. Backs the PR sweeps, which drain it page by page.
|
|
3516
3552
|
*
|
|
3517
|
-
* Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first),
|
|
3518
|
-
*
|
|
3553
|
+
* Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first), which is
|
|
3554
|
+
* {@link searchPullRequestsPage}'s DEFAULT rather than its only order — that read takes a `criteria.sort`, this
|
|
3555
|
+
* one does not, because a sweep's recency window is what its page budget is defined against. A caller that stops
|
|
3556
|
+
* before `hasMore` clears — every sweep with a
|
|
3519
3557
|
* page budget — therefore retains a well-defined recency window instead of an arbitrary slice of GitHub's
|
|
3520
3558
|
* relevance ranking.
|
|
3521
3559
|
*/
|
|
@@ -3546,7 +3584,15 @@ export class GitHubApi {
|
|
|
3546
3584
|
cancellation?: AbortSignal,
|
|
3547
3585
|
): Promise<{ values: PullRequest[]; cursor?: string; hasMore: boolean; truncated: boolean }> {
|
|
3548
3586
|
const scope = getScopedLogger();
|
|
3549
|
-
|
|
3587
|
+
// The page follows the projection rather than being a separate decision: the full fragment is what GitHub
|
|
3588
|
+
// rejects at 100 nodes (see `defaultPullRequestSearchPageSize`), so every read that selects it pages at the
|
|
3589
|
+
// reduced size, and only the lite shape asks for the maximum. A caller that wants a whole list pages for it
|
|
3590
|
+
// — see `searchMyPullRequests`.
|
|
3591
|
+
const configuredLimit = this.config.getLaunchpadQueryLimit?.() ?? maxPullRequestSearchPageSize;
|
|
3592
|
+
const limit = Math.min(
|
|
3593
|
+
options?.summary === true ? maxPullRequestSearchPageSize : defaultPullRequestSearchPageSize,
|
|
3594
|
+
configuredLimit,
|
|
3595
|
+
);
|
|
3550
3596
|
|
|
3551
3597
|
try {
|
|
3552
3598
|
interface SearchResult {
|
|
@@ -3654,6 +3700,16 @@ export class GitHubApi {
|
|
|
3654
3700
|
}
|
|
3655
3701
|
}
|
|
3656
3702
|
|
|
3703
|
+
/**
|
|
3704
|
+
* The whole list rather than a page: this read has no cursor to hand back (its `IntegrationResult<T[]>` return
|
|
3705
|
+
* has no paging channel at all), so it drains up to {@link maxMyPullRequestSearchPages} pages itself.
|
|
3706
|
+
*
|
|
3707
|
+
* Draining instead of taking one page is what lets the full projection page at the reduced size everywhere
|
|
3708
|
+
* (see the `limit` in {@link searchMyPullRequestsPage}): a single page would otherwise have to ask for 100
|
|
3709
|
+
* nodes of the selection GitHub rejects at that size, purely because this caller cannot resume. Ordering is
|
|
3710
|
+
* `sort:updated`, so the pages compose into a defined window rather than a shifting relevance ranking, and the
|
|
3711
|
+
* drain stops as soon as GitHub reports no next page.
|
|
3712
|
+
*/
|
|
3657
3713
|
@trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
|
|
3658
3714
|
async searchMyPullRequests(
|
|
3659
3715
|
provider: Provider,
|
|
@@ -3669,9 +3725,45 @@ export class GitHubApi {
|
|
|
3669
3725
|
},
|
|
3670
3726
|
cancellation?: AbortSignal,
|
|
3671
3727
|
): Promise<PullRequest[]> {
|
|
3672
|
-
|
|
3728
|
+
const values: PullRequest[] = [];
|
|
3729
|
+
// The searches behind one page can overlap (a PR can be authored by and review-requested from the same
|
|
3730
|
+
// user), and a later page can repeat a row an earlier one already served if the PR was updated mid-drain.
|
|
3731
|
+
const seen = new Set<string>();
|
|
3732
|
+
let cursor: string | undefined;
|
|
3733
|
+
|
|
3734
|
+
for (let page = 0; page < maxMyPullRequestSearchPages; page++) {
|
|
3735
|
+
const result = await this.searchMyPullRequestsPage(
|
|
3736
|
+
provider,
|
|
3737
|
+
token,
|
|
3738
|
+
{ ...options, cursor: cursor },
|
|
3739
|
+
cancellation,
|
|
3740
|
+
);
|
|
3741
|
+
for (const pr of result.values) {
|
|
3742
|
+
if (seen.has(pr.url)) continue;
|
|
3743
|
+
|
|
3744
|
+
seen.add(pr.url);
|
|
3745
|
+
values.push(pr);
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
// A missing or repeated cursor with `hasMore` would loop on the same page forever.
|
|
3749
|
+
if (!result.hasMore || result.cursor == null || result.cursor === cursor) break;
|
|
3750
|
+
|
|
3751
|
+
cursor = result.cursor;
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
return values;
|
|
3673
3755
|
}
|
|
3674
3756
|
|
|
3757
|
+
/**
|
|
3758
|
+
* The current user's issues: authored ∪ assigned ∪ mentioned, each its own aliased search behind one composite
|
|
3759
|
+
* cursor. Bound to `@me` by construction, unlike {@link searchIssuesPage}.
|
|
3760
|
+
*
|
|
3761
|
+
* Ordering is OPT-IN here, and that asymmetry with {@link searchIssuesPage} is deliberate: this read has never
|
|
3762
|
+
* requested a sort, so GitHub has always answered it in relevance order. Emitting a default would change which
|
|
3763
|
+
* issues its already-shipped consumers see, so an omitted `sort` still emits no `sort:` qualifier at all and
|
|
3764
|
+
* keeps today's result. Pass one to get a defined order — which is also what makes a page budget meaningful,
|
|
3765
|
+
* since relevance ranking can shift under an unchanged upstream.
|
|
3766
|
+
*/
|
|
3675
3767
|
@trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
|
|
3676
3768
|
async searchMyIssues(
|
|
3677
3769
|
provider: Provider,
|
|
@@ -3685,6 +3777,8 @@ export class GitHubApi {
|
|
|
3685
3777
|
includeBody?: boolean;
|
|
3686
3778
|
includeAllAssignees?: boolean;
|
|
3687
3779
|
cursor?: string;
|
|
3780
|
+
/** Requested order. Omitted leaves GitHub's relevance order, which is what this read has always served. */
|
|
3781
|
+
sort?: IssueSorting;
|
|
3688
3782
|
/**
|
|
3689
3783
|
* Which of the three "my issues" searches to run. Omitted runs all three (GitHub's own definition of
|
|
3690
3784
|
* "mine": authored ∪ assigned ∪ mentioned). Supplied, only the `true` ones run — so a caller wanting
|
|
@@ -3710,7 +3804,10 @@ export class GitHubApi {
|
|
|
3710
3804
|
search += `${repo}${options.repos.join(repo)}`;
|
|
3711
3805
|
}
|
|
3712
3806
|
|
|
3713
|
-
|
|
3807
|
+
// A requested sort goes through the same table `searchIssuesPage` uses, so the two GitHub issue reads can't
|
|
3808
|
+
// diverge the first time a key is added. Omitted appends nothing — see this method's contract above.
|
|
3809
|
+
const sortQualifier = toGitHubIssueSortQualifier(options?.sort);
|
|
3810
|
+
const baseFilters = ['type:issue is:open archived:false', sortQualifier].filter(Boolean).join(' ');
|
|
3714
3811
|
// `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
|
|
3715
3812
|
// (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
|
|
3716
3813
|
// user-relative by definition, so an all-assignees read still only surfaces the current user's authored
|
|
@@ -3748,7 +3845,24 @@ export class GitHubApi {
|
|
|
3748
3845
|
searches.push({ alias: 'authored', query: `${search} ${baseFilters} author:@me`.trim() });
|
|
3749
3846
|
}
|
|
3750
3847
|
|
|
3751
|
-
|
|
3848
|
+
// Field by field, like `searchIssuesPage`: `options` also carries `repos`/`includeAllAssignees`/`categories`,
|
|
3849
|
+
// already folded into `searches[].query` above and undeclared by the callee.
|
|
3850
|
+
return this.searchIssuesByAlias(
|
|
3851
|
+
provider,
|
|
3852
|
+
token,
|
|
3853
|
+
searches,
|
|
3854
|
+
{
|
|
3855
|
+
baseUrl: options?.baseUrl,
|
|
3856
|
+
avatarSize: options?.avatarSize,
|
|
3857
|
+
includeBody: options?.includeBody,
|
|
3858
|
+
cursor: options?.cursor,
|
|
3859
|
+
sort: options?.sort,
|
|
3860
|
+
// This read emitted no `sort:` qualifier at all before ordering existed, so a cursor with no
|
|
3861
|
+
// recorded key came out of a relevance-ordered walk.
|
|
3862
|
+
legacySort: unsortedCursorSort,
|
|
3863
|
+
},
|
|
3864
|
+
cancellation,
|
|
3865
|
+
);
|
|
3752
3866
|
}
|
|
3753
3867
|
|
|
3754
3868
|
/**
|
|
@@ -3756,9 +3870,15 @@ export class GitHubApi {
|
|
|
3756
3870
|
* relationship to the current user. The issue counterpart of {@link searchMyPullRequestsPage}, and distinct
|
|
3757
3871
|
* from {@link searchMyIssues}, which is permanently bound to `@me`.
|
|
3758
3872
|
*
|
|
3759
|
-
* Ordering is
|
|
3760
|
-
*
|
|
3761
|
-
* order
|
|
3873
|
+
* Ordering is `criteria.sort`, defaulting to most-recently-updated-first — the order this read served before
|
|
3874
|
+
* ordering was an option, so an omitted `sort` emits the identical query. What is NOT optional is that SOME
|
|
3875
|
+
* order is always requested: without one GitHub answers in relevance order, and at the result ceiling that
|
|
3876
|
+
* makes which rows are reachable a function of GitHub's ranking rather than of the request. A key GitHub can't
|
|
3877
|
+
* express (`closed`, `priority`, …) is refused by the facade before the request, not silently downgraded.
|
|
3878
|
+
*
|
|
3879
|
+
* With more than one relationship the page is a UNION of several searches, each ordered by the provider; the
|
|
3880
|
+
* merged page is re-sorted here so the whole page honors the requested key. Across pages the order is still
|
|
3881
|
+
* per-alias — see {@link searchIssuesByAlias}.
|
|
3762
3882
|
*
|
|
3763
3883
|
* Each requested relationship becomes its own aliased search, unioned and deduped by url; with none, a single
|
|
3764
3884
|
* search runs over the scope alone. `criteria.text` and the other free-form values are sanitized so user input
|
|
@@ -3780,9 +3900,12 @@ export class GitHubApi {
|
|
|
3780
3900
|
},
|
|
3781
3901
|
cancellation?: AbortSignal,
|
|
3782
3902
|
): Promise<AliasedIssueSearchResult | undefined> {
|
|
3903
|
+
// Resolved once: the emitted qualifier, the merged page's comparator and the cursor's fingerprint must all
|
|
3904
|
+
// be the same key, which is what `effectiveIssueSort` exists to guarantee.
|
|
3905
|
+
const sort = effectiveIssueSort(options?.criteria?.sort);
|
|
3783
3906
|
const base = [
|
|
3784
3907
|
...toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos),
|
|
3785
|
-
...toGitHubIssueSearchQualifiers(options?.criteria),
|
|
3908
|
+
...toGitHubIssueSearchQualifiers(options?.criteria, sort),
|
|
3786
3909
|
].join(' ');
|
|
3787
3910
|
|
|
3788
3911
|
// One aliased search per relationship, OR-ed by union. They can't be one query: GitHub AND-s qualifiers,
|
|
@@ -3796,7 +3919,27 @@ export class GitHubApi {
|
|
|
3796
3919
|
}))
|
|
3797
3920
|
: [{ alias: 'matched', query: base }];
|
|
3798
3921
|
|
|
3799
|
-
|
|
3922
|
+
// Forwarded field by field rather than spread: `options` also carries `repos`/`org`/`criteria`, which are
|
|
3923
|
+
// already baked into `searches[].query` above and which the callee declares nothing about. `sort` is the
|
|
3924
|
+
// EFFECTIVE key, since the merged page and the cursor's fingerprint must both use the one the query used.
|
|
3925
|
+
return this.searchIssuesByAlias(
|
|
3926
|
+
provider,
|
|
3927
|
+
token,
|
|
3928
|
+
searches,
|
|
3929
|
+
{
|
|
3930
|
+
baseUrl: options?.baseUrl,
|
|
3931
|
+
avatarSize: options?.avatarSize,
|
|
3932
|
+
includeBody: options?.includeBody,
|
|
3933
|
+
cursor: options?.cursor,
|
|
3934
|
+
pageSize: options?.pageSize,
|
|
3935
|
+
sort: sort,
|
|
3936
|
+
// This read has always emitted `sort:updated`, which is `defaultIssueSort` — so a cursor with no
|
|
3937
|
+
// recorded key came out of a walk under exactly that key, and only a caller asking for a
|
|
3938
|
+
// different one has to restart.
|
|
3939
|
+
legacySort: defaultIssueSort,
|
|
3940
|
+
},
|
|
3941
|
+
cancellation,
|
|
3942
|
+
);
|
|
3800
3943
|
}
|
|
3801
3944
|
|
|
3802
3945
|
/**
|
|
@@ -3823,9 +3966,12 @@ export class GitHubApi {
|
|
|
3823
3966
|
if (scopes.length === 0) return [];
|
|
3824
3967
|
|
|
3825
3968
|
const queries = scopes.map(s => {
|
|
3969
|
+
// The same resolved key the search would use, so the count previews the query it previews. Ordering
|
|
3970
|
+
// cannot change a total, but emitting a DIFFERENT qualifier string than the search does would break the
|
|
3971
|
+
// parity this probe is for.
|
|
3826
3972
|
const qualifiers = [
|
|
3827
3973
|
...toGitHubIssueSearchScopeQualifiers(s.org, s.repos),
|
|
3828
|
-
...toGitHubIssueSearchQualifiers(s.criteria),
|
|
3974
|
+
...toGitHubIssueSearchQualifiers(s.criteria, effectiveIssueSort(s.criteria?.sort)),
|
|
3829
3975
|
];
|
|
3830
3976
|
|
|
3831
3977
|
// A relationship set is OR-ed across searches, which a single count can't express — the facade splits
|
|
@@ -3872,6 +4018,85 @@ export class GitHubApi {
|
|
|
3872
4018
|
}
|
|
3873
4019
|
}
|
|
3874
4020
|
|
|
4021
|
+
/**
|
|
4022
|
+
* The PR twin of {@link countIssues}: counts pull requests for several scopes in ONE request via aliased
|
|
4023
|
+
* `search` fields selecting only `issueCount` with `first: 0`. Same positional/undefined contract as the issue
|
|
4024
|
+
* count.
|
|
4025
|
+
*
|
|
4026
|
+
* The one difference is states. {@link toGitHubPullRequestSearchFacets} fans a scope's states out into one
|
|
4027
|
+
* `search` each, so this reports the count the SAME way {@link searchPullRequestsPage} reports its total — the
|
|
4028
|
+
* LARGEST facet's `issueCount`, not their sum: the result ceiling applies per search, so the max is what
|
|
4029
|
+
* `exceedsProviderLimit` compares against, and summing would claim a total the read never surfaces. Relationships
|
|
4030
|
+
* are OR-ed and can't be a single count; the facade refuses a multi-relationship scope, so at most one is here.
|
|
4031
|
+
*/
|
|
4032
|
+
@trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
|
|
4033
|
+
async countPullRequests(
|
|
4034
|
+
provider: Provider,
|
|
4035
|
+
token: GitHubTokenInfo,
|
|
4036
|
+
scopes: readonly { repos?: string[]; org?: string; criteria?: PullRequestSearchCriteria }[],
|
|
4037
|
+
options?: { baseUrl?: string },
|
|
4038
|
+
cancellation?: AbortSignal,
|
|
4039
|
+
): Promise<(number | undefined)[]> {
|
|
4040
|
+
const scope = getScopedLogger();
|
|
4041
|
+
if (scopes.length === 0) return [];
|
|
4042
|
+
|
|
4043
|
+
// Each scope expands to one query string per state facet (all sharing its single relationship, which the
|
|
4044
|
+
// facade guarantees). A scope's count is the MAX across its facets — mirroring searchPullRequestsPage's
|
|
4045
|
+
// totalCount — so the aliases stay grouped by scope and are reduced after the response.
|
|
4046
|
+
const scopeQueries = scopes.map(s => {
|
|
4047
|
+
const scopeQualifiers = toGitHubIssueSearchScopeQualifiers(s.org, s.repos);
|
|
4048
|
+
return toGitHubPullRequestSearchFacets(s.criteria).map(f =>
|
|
4049
|
+
[...scopeQualifiers, ...f.qualifiers].join(' '),
|
|
4050
|
+
);
|
|
4051
|
+
});
|
|
4052
|
+
|
|
4053
|
+
// Aliases are positional and generated (`s${scope}f${facet}`): a caller's key is arbitrary text and would
|
|
4054
|
+
// break the document, so results are reduced back to one count per scope by index.
|
|
4055
|
+
const aliased = scopeQueries.flatMap((queries, si) =>
|
|
4056
|
+
queries.map((query, fi) => ({ alias: `s${si}f${fi}`, query: query })),
|
|
4057
|
+
);
|
|
4058
|
+
const params = aliased.map(a => `$${a.alias}: String!`).join('\n\t\t\t\t');
|
|
4059
|
+
// `first: 0` is what makes this cheap — `issueCount` alone, no nodes over the wire.
|
|
4060
|
+
const fields = aliased
|
|
4061
|
+
.map(a => `${a.alias}: search(query: $${a.alias}, type: ISSUE, first: 0) { issueCount }`)
|
|
4062
|
+
.join('\n\t\t\t\t');
|
|
4063
|
+
const query = `query countPullRequests(
|
|
4064
|
+
${params}
|
|
4065
|
+
) {
|
|
4066
|
+
${fields}
|
|
4067
|
+
}`;
|
|
4068
|
+
|
|
4069
|
+
const variables: Record<string, unknown> = { baseUrl: options?.baseUrl };
|
|
4070
|
+
for (const a of aliased) {
|
|
4071
|
+
variables[a.alias] = a.query;
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
try {
|
|
4075
|
+
const rsp = await this.graphql<Record<string, { issueCount?: number } | undefined>>(
|
|
4076
|
+
provider,
|
|
4077
|
+
token,
|
|
4078
|
+
query,
|
|
4079
|
+
variables,
|
|
4080
|
+
scope,
|
|
4081
|
+
cancellation,
|
|
4082
|
+
);
|
|
4083
|
+
if (rsp == null) return scopes.map(() => undefined);
|
|
4084
|
+
|
|
4085
|
+
return scopeQueries.map((queries, si) => {
|
|
4086
|
+
let max: number | undefined;
|
|
4087
|
+
for (let fi = 0; fi < queries.length; fi++) {
|
|
4088
|
+
const count = rsp[`s${si}f${fi}`]?.issueCount;
|
|
4089
|
+
if (count != null) {
|
|
4090
|
+
max = max == null ? count : Math.max(max, count);
|
|
4091
|
+
}
|
|
4092
|
+
}
|
|
4093
|
+
return max;
|
|
4094
|
+
});
|
|
4095
|
+
} catch (ex) {
|
|
4096
|
+
throw this.handleException(ex, provider, scope);
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
|
|
3875
4100
|
/**
|
|
3876
4101
|
* The aliased-search engine behind every GitHub issue search: one GraphQL request carrying N independently
|
|
3877
4102
|
* cursored `search` fields, `@include`-gated so an exhausted or unrequested one costs nothing.
|
|
@@ -3886,14 +4111,31 @@ export class GitHubApi {
|
|
|
3886
4111
|
* {@link searchMyIssues} is one configuration of it (its three `@me` categories), and its alias names are
|
|
3887
4112
|
* that read's published cursor keys.
|
|
3888
4113
|
*
|
|
3889
|
-
* `searches` must have unique aliases, each a valid GraphQL name that is
|
|
3890
|
-
* the composite cursor keys aliases at its top level, alongside those
|
|
4114
|
+
* `searches` must have unique aliases, each a valid GraphQL name that is none of `page`, `truncated` or
|
|
4115
|
+
* `sort` — the composite cursor keys aliases at its top level, alongside those three reserved fields.
|
|
4116
|
+
*
|
|
4117
|
+
* `sort` is the order the caller asked for, which this does two things with. Each alias comes back ordered by
|
|
4118
|
+
* it (the qualifier is already in `searches[].query`), but the UNION of several aliases is not, so the merged
|
|
4119
|
+
* page is re-sorted here; and the key is recorded in the cursor, so a continuation that changed it THROWS
|
|
4120
|
+
* rather than serving a sequence with gaps and repeats. Omitted means the caller asked for no order at all
|
|
4121
|
+
* ({@link searchMyIssues}'s default), which re-sorts nothing and pins nothing.
|
|
4122
|
+
*
|
|
4123
|
+
* `legacySort` is the order the calling read produced BEFORE this field existed, and is what a cursor with no
|
|
4124
|
+
* recorded key is compared against — such a cursor is not of unknown order, it is of that read's old one.
|
|
3891
4125
|
*/
|
|
3892
4126
|
private async searchIssuesByAlias(
|
|
3893
4127
|
provider: Provider,
|
|
3894
4128
|
token: GitHubTokenInfo,
|
|
3895
4129
|
searches: readonly AliasedIssueSearch[],
|
|
3896
|
-
options
|
|
4130
|
+
options: {
|
|
4131
|
+
baseUrl?: string;
|
|
4132
|
+
avatarSize?: number;
|
|
4133
|
+
includeBody?: boolean;
|
|
4134
|
+
cursor?: string;
|
|
4135
|
+
pageSize?: number;
|
|
4136
|
+
sort?: IssueSorting;
|
|
4137
|
+
legacySort: IssueSorting | typeof unsortedCursorSort;
|
|
4138
|
+
},
|
|
3897
4139
|
cancellation?: AbortSignal,
|
|
3898
4140
|
): Promise<AliasedIssueSearchResult | undefined> {
|
|
3899
4141
|
const scope = getScopedLogger();
|
|
@@ -3914,6 +4156,16 @@ export class GitHubApi {
|
|
|
3914
4156
|
interface SearchCursor {
|
|
3915
4157
|
page?: number;
|
|
3916
4158
|
truncated?: boolean;
|
|
4159
|
+
/**
|
|
4160
|
+
* The order this cursor's pages were produced under: an `IssueSorting`, or `unsortedCursorSort` when
|
|
4161
|
+
* the caller asked for none. Written as a value rather than left absent in the no-order case
|
|
4162
|
+
* specifically so that ABSENT keeps meaning "cursor from before ordering existed", which is accepted
|
|
4163
|
+
* and sealed instead of refused — a consumer's persisted cursor has to keep working across this change.
|
|
4164
|
+
*
|
|
4165
|
+
* Those three cases are the whole domain, so it is typed as them rather than as `string`: a foreign
|
|
4166
|
+
* cursor carrying something else is caught by the mismatch check either way.
|
|
4167
|
+
*/
|
|
4168
|
+
sort?: IssueSorting | typeof unsortedCursorSort;
|
|
3917
4169
|
[alias: string]: string | number | boolean | null | undefined;
|
|
3918
4170
|
}
|
|
3919
4171
|
|
|
@@ -3922,19 +4174,54 @@ export class GitHubApi {
|
|
|
3922
4174
|
// by a cursor string that reads back as page 1, restarting the walk with no error and no truncation flag.
|
|
3923
4175
|
// Cheap to check, and it fails at the one call that introduced the collision rather than in a consumer's
|
|
3924
4176
|
// persisted cursor.
|
|
3925
|
-
const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated');
|
|
4177
|
+
const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated' || s.alias === 'sort');
|
|
3926
4178
|
if (reserved.length > 0) {
|
|
3927
4179
|
throw new Error(
|
|
3928
4180
|
`Issue search alias(es) ${reserved.map(s => `'${s.alias}'`).join(', ')} collide with the composite cursor's reserved keys`,
|
|
3929
4181
|
);
|
|
3930
4182
|
}
|
|
3931
4183
|
|
|
4184
|
+
// A key GitHub cannot express emits no `sort:` qualifier, so each alias would come back in RELEVANCE order
|
|
4185
|
+
// while the union below is sorted by that key and the cursor sealed under it: an arbitrary subset,
|
|
4186
|
+
// presented as ordered, resumable only into more of the same. Refused here rather than downgraded, which
|
|
4187
|
+
// is the rule the whole feature is built on. Unreachable through the facade — no `supportedIssueSorts`
|
|
4188
|
+
// table declares a key without a qualifier — so this guards the direct callers of the two public reads,
|
|
4189
|
+
// where `title` is expressible enough to have a comparator and not enough to be a GitHub search qualifier.
|
|
4190
|
+
if (options.sort != null && toGitHubIssueSortQualifier(options.sort) == null) {
|
|
4191
|
+
throw new Error(`GitHub cannot order an issue search by '${options.sort}'`);
|
|
4192
|
+
}
|
|
4193
|
+
|
|
3932
4194
|
let cursor: SearchCursor | undefined;
|
|
3933
4195
|
if (options?.cursor != null) {
|
|
3934
4196
|
try {
|
|
3935
4197
|
cursor = JSON.parse(options.cursor) as SearchCursor;
|
|
3936
4198
|
} catch {}
|
|
3937
4199
|
}
|
|
4200
|
+
// The order this request is being made under, as the cursor records it.
|
|
4201
|
+
const requestedSort = options.sort ?? unsortedCursorSort;
|
|
4202
|
+
// A cursor produced under a DIFFERENT order can't be resumed: every alias would continue from a position in
|
|
4203
|
+
// a differently-ordered result set, so the continuation re-emits rows already seen and skips rows never
|
|
4204
|
+
// seen. REFUSED rather than silently restarted from page 1, because a restart cannot be reported honestly
|
|
4205
|
+
// from here: this read is cursor-only, so `resolveCurrentPage` has no page of its own to trust and echoes
|
|
4206
|
+
// the `page` the caller supplied alongside the cursor — page 1's rows would be published as page N, which
|
|
4207
|
+
// is the very confusion the fingerprint exists to prevent. Refusing surfaces a warning + `fetchFailed`, and
|
|
4208
|
+
// the remedy ("drop the cursor") is the caller's to apply.
|
|
4209
|
+
//
|
|
4210
|
+
// A cursor with NO recorded sort predates this field — which is not the same as being of unknown order.
|
|
4211
|
+
// Each read produced exactly one order before ordering was an option (`sort:updated` for the filtered
|
|
4212
|
+
// search, relevance for `searchMyIssues`), so an absent key reads as THAT one, `legacySort`. Compared
|
|
4213
|
+
// rather than waved through: the facade now resolves an omitted key to `defaultIssueSort`, so the
|
|
4214
|
+
// account-wide read's query gained a `sort:updated` qualifier it did not have, and resuming a
|
|
4215
|
+
// relevance-ordered cursor inside it advances each alias through a re-ordered result set — the gaps and
|
|
4216
|
+
// repeats this check exists to prevent, arriving through the very case meant to keep working. A cursor
|
|
4217
|
+
// whose implied key matches the request still resumes, and is sealed with the current one.
|
|
4218
|
+
const cursorSort = cursor?.sort ?? options.legacySort;
|
|
4219
|
+
if (cursor != null && cursorSort !== requestedSort) {
|
|
4220
|
+
throw new Error(
|
|
4221
|
+
`Issue search cursor was produced under sort '${cursorSort}' but '${requestedSort}' was requested; restart the read without a cursor`,
|
|
4222
|
+
);
|
|
4223
|
+
}
|
|
4224
|
+
|
|
3938
4225
|
const page = Math.max(1, Math.trunc(cursor?.page ?? 1));
|
|
3939
4226
|
// A slot is a continuation string, `null` (exhausted), or absent. Anything else came from a malformed or
|
|
3940
4227
|
// foreign cursor, and is read as absent rather than threaded back into the request as a continuation.
|
|
@@ -4023,16 +4310,38 @@ export class GitHubApi {
|
|
|
4023
4310
|
|
|
4024
4311
|
// Dedupe by `url`, not `IssueShape.id`: for some providers `id` is a per-repository number, so an
|
|
4025
4312
|
// id-keyed map would collapse distinct issues across repositories.
|
|
4026
|
-
const
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4313
|
+
const deduped = [
|
|
4314
|
+
...uniqueBy(
|
|
4315
|
+
issues,
|
|
4316
|
+
r => r.url,
|
|
4317
|
+
(original, _current) => original,
|
|
4318
|
+
),
|
|
4319
|
+
];
|
|
4320
|
+
|
|
4321
|
+
// Each alias arrived ordered by the server; their concatenation is not, so the merged page is ordered
|
|
4322
|
+
// here. AFTER the dedupe, not before, and that ordering is load-bearing: the alias order is also the
|
|
4323
|
+
// dedupe's precedence (an issue both assigned to and authored by the user surfaces as the assigned one,
|
|
4324
|
+
// per `searchMyIssues`), and sorting first would hand `uniqueBy` a different first occurrence and
|
|
4325
|
+
// silently change which copy wins. The pull-request path sorts BEFORE its dedupe because its facets
|
|
4326
|
+
// carry no such precedence — the difference is deliberate, not an inconsistency to tidy up.
|
|
4327
|
+
//
|
|
4328
|
+
// A comparator is always available for a key GitHub declares (`created`/`updated`/`comments`/
|
|
4329
|
+
// `reactions` are all on `IssueShape`), so `undefined` here means the capability table has outrun this
|
|
4330
|
+
// read; leave the provider's per-alias order rather than inventing one.
|
|
4331
|
+
//
|
|
4332
|
+
// Counted over `active`, not `searches`: continuations exhaust aliases one at a time, so a later page of
|
|
4333
|
+
// a three-category walk can come from ONE surviving search — already ordered by the server. Re-sorting
|
|
4334
|
+
// it could only reproduce that order, while hiding a provider that ignored the qualifier.
|
|
4335
|
+
const comparator = options?.sort != null ? getIssueComparator(options.sort) : undefined;
|
|
4336
|
+
if (comparator != null && active.length > 1) {
|
|
4337
|
+
deduped.sort(comparator);
|
|
4338
|
+
}
|
|
4031
4339
|
|
|
4032
4340
|
// Every alias gets a slot, so an inactive one keeps its `null` and stays out of the next request. A
|
|
4033
4341
|
// missing slot would be read as "never requested", which for a `searches` set that still lists it
|
|
4034
4342
|
// would restart it from its first page.
|
|
4035
|
-
|
|
4343
|
+
// The order is pinned on the way out too, so the next round can refuse a changed key (see above).
|
|
4344
|
+
const next: SearchCursor = { page: page + 1, sort: requestedSort };
|
|
4036
4345
|
let hasMore = false;
|
|
4037
4346
|
let continuationMissing = false;
|
|
4038
4347
|
let maxIssueCount = 0;
|
|
@@ -4057,7 +4366,7 @@ export class GitHubApi {
|
|
|
4057
4366
|
cursor?.truncated === true || maxIssueCount > githubSearchResultLimit || continuationMissing;
|
|
4058
4367
|
next.truncated = truncated || undefined;
|
|
4059
4368
|
return {
|
|
4060
|
-
values:
|
|
4369
|
+
values: deduped,
|
|
4061
4370
|
cursor: hasMore ? JSON.stringify(next) : undefined,
|
|
4062
4371
|
hasMore: hasMore,
|
|
4063
4372
|
page: page,
|
|
@@ -4074,8 +4383,11 @@ export class GitHubApi {
|
|
|
4074
4383
|
* every active relationship × state facet, so one HTTP request serves one page even when the logical search is
|
|
4075
4384
|
* a union. The cursor preserves each facet's continuation plus the positional page.
|
|
4076
4385
|
*
|
|
4077
|
-
* Ordering is
|
|
4078
|
-
*
|
|
4386
|
+
* Ordering is `criteria.sort`, defaulting to most-recently-updated-first — the order this read served before it
|
|
4387
|
+
* was expressible. It is requested of the provider AND re-applied to the merged page, because the page is a
|
|
4388
|
+
* union of facets and no per-facet server order describes it. The sort is part of the cursor fingerprint, so
|
|
4389
|
+
* changing it invalidates a threaded cursor exactly as changing the text or the scope does. User text is
|
|
4390
|
+
* sanitized before it reaches the provider query. See {@link toGitHubPullRequestSearchFacets}.
|
|
4079
4391
|
*/
|
|
4080
4392
|
@trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
|
|
4081
4393
|
async searchPullRequestsPage(
|
|
@@ -4235,7 +4547,15 @@ export class GitHubApi {
|
|
|
4235
4547
|
}
|
|
4236
4548
|
}
|
|
4237
4549
|
}
|
|
4238
|
-
|
|
4550
|
+
// The PR path re-sorts the merged page rather than trusting the per-facet server order — GitHub's
|
|
4551
|
+
// server-side PR sort has been unreliable (the per-branch path re-sorts for the same reason), and the
|
|
4552
|
+
// union of several facets is unordered regardless. The key was validated against
|
|
4553
|
+
// `githubPullRequestSearchCapabilities.sorts` upstream, so its comparator is always defined for a PR
|
|
4554
|
+
// shape; the guard only guards the unreachable case rather than inventing an order for it.
|
|
4555
|
+
const comparator = getPullRequestComparator(options?.criteria?.sort ?? defaultPullRequestSort);
|
|
4556
|
+
if (comparator != null) {
|
|
4557
|
+
pullRequests.sort(comparator);
|
|
4558
|
+
}
|
|
4239
4559
|
const values = [
|
|
4240
4560
|
...uniqueBy(
|
|
4241
4561
|
pullRequests,
|