@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.
Files changed (190) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/git/models/issue.d.ts +70 -0
  3. package/dist/git/models/issue.d.ts.map +1 -1
  4. package/dist/git/models/issue.js +17 -0
  5. package/dist/git/models/issue.js.map +1 -1
  6. package/dist/git/models/pullRequest.d.ts +81 -0
  7. package/dist/git/models/pullRequest.d.ts.map +1 -1
  8. package/dist/git/models/pullRequest.js +15 -0
  9. package/dist/git/models/pullRequest.js.map +1 -1
  10. package/dist/git/utils/issue.utils.d.ts +41 -1
  11. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  12. package/dist/git/utils/issue.utils.js +80 -0
  13. package/dist/git/utils/issue.utils.js.map +1 -1
  14. package/dist/git/utils/pullRequest.utils.d.ts +11 -1
  15. package/dist/git/utils/pullRequest.utils.d.ts.map +1 -1
  16. package/dist/git/utils/pullRequest.utils.js +40 -0
  17. package/dist/git/utils/pullRequest.utils.js.map +1 -1
  18. package/dist/plus/git-github/api/github.d.ts +70 -10
  19. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  20. package/dist/plus/git-github/api/github.js +284 -31
  21. package/dist/plus/git-github/api/github.js.map +1 -1
  22. package/dist/plus/git-github/api/issueSearchQuery.d.ts +49 -5
  23. package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -1
  24. package/dist/plus/git-github/api/issueSearchQuery.js +64 -6
  25. package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -1
  26. package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts +27 -1
  27. package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts.map +1 -1
  28. package/dist/plus/git-github/api/pullRequestSearchQuery.js +73 -6
  29. package/dist/plus/git-github/api/pullRequestSearchQuery.js.map +1 -1
  30. package/dist/plus/git-github/models.d.ts +17 -5
  31. package/dist/plus/git-github/models.d.ts.map +1 -1
  32. package/dist/plus/git-github/models.js +14 -1
  33. package/dist/plus/git-github/models.js.map +1 -1
  34. package/dist/plus/integrations/collectionMetadata.d.ts +18 -0
  35. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  36. package/dist/plus/integrations/collectionMetadata.js +24 -0
  37. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  38. package/dist/plus/integrations/integrationService.d.ts +31 -10
  39. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  40. package/dist/plus/integrations/integrationService.js +9 -1
  41. package/dist/plus/integrations/integrationService.js.map +1 -1
  42. package/dist/plus/integrations/manager.d.ts +85 -31
  43. package/dist/plus/integrations/manager.d.ts.map +1 -1
  44. package/dist/plus/integrations/models/gitHostIntegration.d.ts +18 -31
  45. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  46. package/dist/plus/integrations/models/gitHostIntegration.js +29 -1
  47. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  48. package/dist/plus/integrations/models/integration.d.ts +1 -1
  49. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  50. package/dist/plus/integrations/models/integration.js.map +1 -1
  51. package/dist/plus/integrations/models/issueReads.d.ts +63 -1
  52. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -1
  53. package/dist/plus/integrations/models/issuesIntegration.d.ts +7 -21
  54. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  55. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  56. package/dist/plus/integrations/providerFilters.d.ts +2 -2
  57. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  58. package/dist/plus/integrations/providers/accounts.d.ts +6 -0
  59. package/dist/plus/integrations/providers/accounts.d.ts.map +1 -0
  60. package/dist/plus/integrations/providers/accounts.js +30 -0
  61. package/dist/plus/integrations/providers/accounts.js.map +1 -0
  62. package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
  63. package/dist/plus/integrations/providers/azureDevOps.js +1 -0
  64. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  65. package/dist/plus/integrations/providers/github.d.ts +9 -0
  66. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  67. package/dist/plus/integrations/providers/github.js +115 -135
  68. package/dist/plus/integrations/providers/github.js.map +1 -1
  69. package/dist/plus/integrations/providers/gitlab.d.ts +9 -0
  70. package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
  71. package/dist/plus/integrations/providers/gitlab.js +24 -4
  72. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  73. package/dist/plus/integrations/providers/issueSorts.d.ts +36 -0
  74. package/dist/plus/integrations/providers/issueSorts.d.ts.map +1 -0
  75. package/dist/plus/integrations/providers/issueSorts.js +76 -0
  76. package/dist/plus/integrations/providers/issueSorts.js.map +1 -0
  77. package/dist/plus/integrations/providers/jira.d.ts +3 -9
  78. package/dist/plus/integrations/providers/jira.d.ts.map +1 -1
  79. package/dist/plus/integrations/providers/jira.js +5 -1
  80. package/dist/plus/integrations/providers/jira.js.map +1 -1
  81. package/dist/plus/integrations/providers/linear.d.ts +24 -9
  82. package/dist/plus/integrations/providers/linear.d.ts.map +1 -1
  83. package/dist/plus/integrations/providers/linear.js +63 -5
  84. package/dist/plus/integrations/providers/linear.js.map +1 -1
  85. package/dist/plus/integrations/providers/models.d.ts +76 -38
  86. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  87. package/dist/plus/integrations/providers/models.js +91 -105
  88. package/dist/plus/integrations/providers/models.js.map +1 -1
  89. package/dist/plus/integrations/providers/providersApi.d.ts +18 -3
  90. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  91. package/dist/plus/integrations/providers/providersApi.js +24 -7
  92. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  93. package/dist/plus/integrations/providers/pullRequestReviews.d.ts +50 -0
  94. package/dist/plus/integrations/providers/pullRequestReviews.d.ts.map +1 -0
  95. package/dist/plus/integrations/providers/pullRequestReviews.js +99 -0
  96. package/dist/plus/integrations/providers/pullRequestReviews.js.map +1 -0
  97. package/dist/plus/integrations/providers/trello.d.ts +3 -9
  98. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  99. package/dist/plus/integrations/providers/trello.js +1 -0
  100. package/dist/plus/integrations/providers/trello.js.map +1 -1
  101. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  102. package/dist/plus/integrations/providers/utils/providerPaging.js +5 -1
  103. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  104. package/dist/plus/integrations/reads/broaden.d.ts +6 -0
  105. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  106. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  107. package/dist/plus/integrations/reads/counts.d.ts +63 -1
  108. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  109. package/dist/plus/integrations/reads/counts.js +146 -2
  110. package/dist/plus/integrations/reads/counts.js.map +1 -1
  111. package/dist/plus/integrations/reads/drains.d.ts +7 -1
  112. package/dist/plus/integrations/reads/drains.d.ts.map +1 -1
  113. package/dist/plus/integrations/reads/drains.js +13 -2
  114. package/dist/plus/integrations/reads/drains.js.map +1 -1
  115. package/dist/plus/integrations/reads/filters.d.ts +59 -9
  116. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  117. package/dist/plus/integrations/reads/filters.js +39 -0
  118. package/dist/plus/integrations/reads/filters.js.map +1 -1
  119. package/dist/plus/integrations/reads/issueTracker.d.ts +18 -1
  120. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  121. package/dist/plus/integrations/reads/issueTracker.js +40 -3
  122. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  123. package/dist/plus/integrations/reads/issues.d.ts +27 -3
  124. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  125. package/dist/plus/integrations/reads/issues.js +223 -123
  126. package/dist/plus/integrations/reads/issues.js.map +1 -1
  127. package/dist/plus/integrations/reads/ordering.d.ts +101 -0
  128. package/dist/plus/integrations/reads/ordering.d.ts.map +1 -0
  129. package/dist/plus/integrations/reads/ordering.js +48 -0
  130. package/dist/plus/integrations/reads/ordering.js.map +1 -0
  131. package/dist/plus/integrations/reads/searchIssues.d.ts +7 -2
  132. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
  133. package/dist/plus/integrations/reads/searchIssues.js +9 -3
  134. package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
  135. package/dist/plus/integrations/reads/searchPullRequests.d.ts +4 -3
  136. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
  137. package/dist/plus/integrations/reads/searchPullRequests.js +6 -4
  138. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
  139. package/dist/plus/integrations/reads/sweeps.js +1 -1
  140. package/dist/plus/integrations/reads/sweeps.js.map +1 -1
  141. package/dist/plus/integrations/reads/warnings.d.ts +35 -3
  142. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  143. package/dist/plus/integrations/reads/warnings.js +58 -4
  144. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  145. package/dist/plus/integrations/results.d.ts +15 -0
  146. package/dist/plus/integrations/results.d.ts.map +1 -1
  147. package/dist/plus/integrations/results.js.map +1 -1
  148. package/docs/integrations.md +76 -10
  149. package/docs/kepler-read-api-parity.md +9 -0
  150. package/package.json +2 -2
  151. package/src/git/models/issue.ts +73 -0
  152. package/src/git/models/pullRequest.ts +84 -0
  153. package/src/git/utils/issue.utils.ts +91 -1
  154. package/src/git/utils/pullRequest.utils.ts +50 -0
  155. package/src/plus/git-github/api/github.ts +356 -36
  156. package/src/plus/git-github/api/issueSearchQuery.ts +67 -7
  157. package/src/plus/git-github/api/pullRequestSearchQuery.ts +81 -7
  158. package/src/plus/git-github/models.ts +29 -5
  159. package/src/plus/integrations/collectionMetadata.ts +24 -0
  160. package/src/plus/integrations/integrationService.ts +40 -12
  161. package/src/plus/integrations/manager.ts +89 -35
  162. package/src/plus/integrations/models/gitHostIntegration.ts +47 -31
  163. package/src/plus/integrations/models/integration.ts +1 -0
  164. package/src/plus/integrations/models/issueReads.ts +65 -1
  165. package/src/plus/integrations/models/issuesIntegration.ts +7 -6
  166. package/src/plus/integrations/providerFilters.ts +8 -1
  167. package/src/plus/integrations/providers/accounts.ts +34 -0
  168. package/src/plus/integrations/providers/azureDevOps.ts +1 -0
  169. package/src/plus/integrations/providers/github.ts +134 -144
  170. package/src/plus/integrations/providers/gitlab.ts +29 -10
  171. package/src/plus/integrations/providers/issueSorts.ts +89 -0
  172. package/src/plus/integrations/providers/jira.ts +9 -3
  173. package/src/plus/integrations/providers/linear.ts +77 -8
  174. package/src/plus/integrations/providers/models.ts +185 -128
  175. package/src/plus/integrations/providers/providersApi.ts +51 -13
  176. package/src/plus/integrations/providers/pullRequestReviews.ts +122 -0
  177. package/src/plus/integrations/providers/trello.ts +4 -3
  178. package/src/plus/integrations/providers/utils/providerPaging.ts +6 -1
  179. package/src/plus/integrations/reads/broaden.ts +6 -0
  180. package/src/plus/integrations/reads/counts.ts +281 -3
  181. package/src/plus/integrations/reads/drains.ts +12 -2
  182. package/src/plus/integrations/reads/filters.ts +92 -8
  183. package/src/plus/integrations/reads/issueTracker.ts +78 -4
  184. package/src/plus/integrations/reads/issues.ts +386 -194
  185. package/src/plus/integrations/reads/ordering.ts +124 -0
  186. package/src/plus/integrations/reads/searchIssues.ts +15 -3
  187. package/src/plus/integrations/reads/searchPullRequests.ts +12 -4
  188. package/src/plus/integrations/reads/sweeps.ts +1 -1
  189. package/src/plus/integrations/reads/warnings.ts +84 -2
  190. 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
- // while callers that know their scope is cheap can still opt into the supported 100-node max.
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
- author {
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
- * matching {@link searchPullRequestsPage}. A caller that stops before `hasMore` clears every sweep with a
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 orderthat 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
- const limit = Math.min(100, this.config.getLaunchpadQueryLimit?.() ?? 100);
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
- return (await this.searchMyPullRequestsPage(provider, token, options, cancellation)).values;
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
- const baseFilters = 'type:issue is:open archived:false';
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
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
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 part of the contract, not an option: always `sort:updated` (most recently updated first). A
3760
- * consumer's "show the N most recent" policy at GitHub's result ceiling is only correct under a guaranteed
3761
- * order, and an option would let a caller pick relevance order and then truncate to an arbitrary subset.
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
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
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 neither `page` nor `truncated`
3890
- * the composite cursor keys aliases at its top level, alongside those two reserved fields.
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?: { baseUrl?: string; avatarSize?: number; includeBody?: boolean; cursor?: string; pageSize?: number },
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 results: IterableIterator<IssueShape> = uniqueBy(
4027
- issues,
4028
- r => r.url,
4029
- (original, _current) => original,
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
- const next: SearchCursor = { page: page + 1 };
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: [...results],
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 always most-recently-updated-first, and user text is sanitized before it reaches the provider
4078
- * query. See {@link toGitHubPullRequestSearchFacets}.
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
- pullRequests.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime());
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,