@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
@@ -8,8 +8,11 @@ import { graphql, GraphqlResponseError } from '@octokit/graphql';
8
8
  import { request } from '@octokit/request';
9
9
  import { RequestError } from '@octokit/request-error';
10
10
  import { AuthenticationError, AuthenticationErrorReason, RequestClientError, RequestNotFoundError, RequestRateLimitError, } from '../../../git/errors.js';
11
- import { PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
11
+ import { defaultIssueSort } from '../../../git/models/issue.js';
12
+ import { defaultPullRequestSort, PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
12
13
  import { getGitHubNoReplyAddressParts } from '../../../git/remotes/github.js';
14
+ import { effectiveIssueSort, getIssueComparator } from '../../../git/utils/issue.utils.js';
15
+ import { getPullRequestComparator } from '../../../git/utils/pullRequest.utils.js';
13
16
  import { createRevisionRange, getRevisionRangeParts, isRevisionRange, isSha, } from '../../../git/utils/revision.utils.js';
14
17
  import { chunk } from '../../../utils/array.js';
15
18
  import { base64 } from '../../../utils/base64.js';
@@ -24,9 +27,18 @@ import { parseUri } from '../../../utils/uri.js';
24
27
  import { fromString, satisfies } from '../../../utils/version.js';
25
28
  import { fromGitHubIssue, fromGitHubIssueOrPullRequestState, fromGitHubPullRequest, fromGitHubPullRequestLite, } from '../models.js';
26
29
  import { githubSearchResultLimit } from './config.js';
27
- import { gitHubIssueSearchRelationships, toGitHubIssueSearchQualifiers, toGitHubIssueSearchScopeQualifiers, } from './issueSearchQuery.js';
30
+ import { gitHubIssueSearchRelationships, toGitHubIssueSearchQualifiers, toGitHubIssueSearchScopeQualifiers, toGitHubIssueSortQualifier, } from './issueSearchQuery.js';
28
31
  import { toGitHubPullRequestSearchFacets } from './pullRequestSearchQuery.js';
29
32
  const emptyPagedResult = Object.freeze({ values: [] });
33
+ /**
34
+ * What an issue-search cursor records when the caller asked for no ordering at all.
35
+ *
36
+ * A sentinel rather than an omitted field, because omitted already means something else and more important: a
37
+ * cursor persisted before ordering existed. Distinguishing the two is what lets an old cursor keep resuming while
38
+ * a genuine change from unordered to ordered is still refused. Not an `IssueSorting`, so it can never collide
39
+ * with one.
40
+ */
41
+ const unsortedCursorSort = 'unsorted';
30
42
  const emptyBlameResult = Object.freeze({ ranges: [] });
31
43
  // Transient gateway/network failures (e.g. an upstream `502 Bad Gateway`) are worth a few quick
32
44
  // retries before surfacing to the caller. octokit provides no built-in retry for the standalone
@@ -40,10 +52,17 @@ const requestRetryMaxDelay = 2000; // ms
40
52
  const accountResolveBatchSize = 25;
41
53
  // Pull-request search selects the full PR fragment (reviews, requests, refs, commits, etc.),
42
54
  // which makes GitHub reject broad 100-node searches with `Resource limits for this query
43
- // exceeded` on large repositories. Thirty keeps the default within that GraphQL cost budget
44
- // while callers that know their scope is cheap can still opt into the supported 100-node max.
55
+ // exceeded` on large repositories. Thirty keeps the default within that GraphQL cost budget;
56
+ // the 100-node maximum the connection accepts is for the lite shape, whose per-node cost is a
57
+ // fraction of it. The budget is per DOCUMENT, and one document holds every relationship × state
58
+ // facet, so the worst case scales with the facet count (five relationships × four states = 20).
45
59
  const defaultPullRequestSearchPageSize = 30;
46
60
  const maxPullRequestSearchPageSize = 100;
61
+ // Pages `searchMyPullRequests` drains for a caller that wants a whole list rather than a page.
62
+ // Four reduced pages cover more than the single 100-node page this read served before it paged,
63
+ // so the smaller page costs coverage nowhere; it bounds an unbounded drain on a read whose
64
+ // consumers (Launchpad, the Graph pull-request panel) block on it.
65
+ const maxMyPullRequestSearchPages = 4;
47
66
  function isRetryableTransientError(ex) {
48
67
  // An aborted request is rethrown as the original `AbortError` (not a `RequestError`), so it is
49
68
  // excluded here. octokit maps a fetch/network failure to a `RequestError` with status 500 and
@@ -142,6 +161,24 @@ repository {
142
161
  viewerPermission
143
162
  }
144
163
  `;
164
+ /**
165
+ * One review's fields, shared by `latestReviews` and `viewerLatestReview`. The two selections must stay
166
+ * identical: `fromGitHubPullRequest` merges the viewer's review into the capped `latestReviews` window and
167
+ * dedups by `id`, so a field present in only one of them would produce rows that differ by where they came
168
+ * from rather than by what they are.
169
+ */
170
+ const gqlPullRequestReviewFragment = `
171
+ id
172
+ author {
173
+ login
174
+ avatarUrl(size: $avatarSize)
175
+ url
176
+ }
177
+ state
178
+ commit {
179
+ oid
180
+ }
181
+ `;
145
182
  const gqlPullRequestFragment = `
146
183
  ${gqlPullRequestLiteFragment}
147
184
  additions
@@ -161,14 +198,12 @@ mergedBy {
161
198
  reviewDecision
162
199
  latestReviews(first: 25) {
163
200
  nodes {
164
- author {
165
- login
166
- avatarUrl(size: $avatarSize)
167
- url
168
- }
169
- state
201
+ ${gqlPullRequestReviewFragment}
170
202
  }
171
203
  }
204
+ viewerLatestReview {
205
+ ${gqlPullRequestReviewFragment}
206
+ }
172
207
  reviewRequests(first: 25) {
173
208
  nodes {
174
209
  asCodeOwner
@@ -2150,14 +2185,21 @@ export class GitHubApi {
2150
2185
  * One page of the current user's pull requests, filtered by state and optionally by an explicit
2151
2186
  * relationship qualifier. Backs the PR sweeps, which drain it page by page.
2152
2187
  *
2153
- * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first),
2154
- * matching {@link searchPullRequestsPage}. A caller that stops before `hasMore` clears every sweep with a
2188
+ * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first), which is
2189
+ * {@link searchPullRequestsPage}'s DEFAULT rather than its only orderthat read takes a `criteria.sort`, this
2190
+ * one does not, because a sweep's recency window is what its page budget is defined against. A caller that stops
2191
+ * before `hasMore` clears — every sweep with a
2155
2192
  * page budget — therefore retains a well-defined recency window instead of an arbitrary slice of GitHub's
2156
2193
  * relevance ranking.
2157
2194
  */
2158
2195
  async searchMyPullRequestsPage(provider, token, options, cancellation) {
2159
2196
  const scope = getScopedLogger();
2160
- const limit = Math.min(100, this.config.getLaunchpadQueryLimit?.() ?? 100);
2197
+ // The page follows the projection rather than being a separate decision: the full fragment is what GitHub
2198
+ // rejects at 100 nodes (see `defaultPullRequestSearchPageSize`), so every read that selects it pages at the
2199
+ // reduced size, and only the lite shape asks for the maximum. A caller that wants a whole list pages for it
2200
+ // — see `searchMyPullRequests`.
2201
+ const configuredLimit = this.config.getLaunchpadQueryLimit?.() ?? maxPullRequestSearchPageSize;
2202
+ const limit = Math.min(options?.summary === true ? maxPullRequestSearchPageSize : defaultPullRequestSearchPageSize, configuredLimit);
2161
2203
  try {
2162
2204
  const query = `query searchMyPullRequests(
2163
2205
  $search: String!
@@ -2236,9 +2278,47 @@ export class GitHubApi {
2236
2278
  throw this.handleException(ex, provider, scope, options?.silent);
2237
2279
  }
2238
2280
  }
2281
+ /**
2282
+ * The whole list rather than a page: this read has no cursor to hand back (its `IntegrationResult<T[]>` return
2283
+ * has no paging channel at all), so it drains up to {@link maxMyPullRequestSearchPages} pages itself.
2284
+ *
2285
+ * Draining instead of taking one page is what lets the full projection page at the reduced size everywhere
2286
+ * (see the `limit` in {@link searchMyPullRequestsPage}): a single page would otherwise have to ask for 100
2287
+ * nodes of the selection GitHub rejects at that size, purely because this caller cannot resume. Ordering is
2288
+ * `sort:updated`, so the pages compose into a defined window rather than a shifting relevance ranking, and the
2289
+ * drain stops as soon as GitHub reports no next page.
2290
+ */
2239
2291
  async searchMyPullRequests(provider, token, options, cancellation) {
2240
- return (await this.searchMyPullRequestsPage(provider, token, options, cancellation)).values;
2292
+ const values = [];
2293
+ // The searches behind one page can overlap (a PR can be authored by and review-requested from the same
2294
+ // user), and a later page can repeat a row an earlier one already served if the PR was updated mid-drain.
2295
+ const seen = new Set();
2296
+ let cursor;
2297
+ for (let page = 0; page < maxMyPullRequestSearchPages; page++) {
2298
+ const result = await this.searchMyPullRequestsPage(provider, token, { ...options, cursor: cursor }, cancellation);
2299
+ for (const pr of result.values) {
2300
+ if (seen.has(pr.url))
2301
+ continue;
2302
+ seen.add(pr.url);
2303
+ values.push(pr);
2304
+ }
2305
+ // A missing or repeated cursor with `hasMore` would loop on the same page forever.
2306
+ if (!result.hasMore || result.cursor == null || result.cursor === cursor)
2307
+ break;
2308
+ cursor = result.cursor;
2309
+ }
2310
+ return values;
2241
2311
  }
2312
+ /**
2313
+ * The current user's issues: authored ∪ assigned ∪ mentioned, each its own aliased search behind one composite
2314
+ * cursor. Bound to `@me` by construction, unlike {@link searchIssuesPage}.
2315
+ *
2316
+ * Ordering is OPT-IN here, and that asymmetry with {@link searchIssuesPage} is deliberate: this read has never
2317
+ * requested a sort, so GitHub has always answered it in relevance order. Emitting a default would change which
2318
+ * issues its already-shipped consumers see, so an omitted `sort` still emits no `sort:` qualifier at all and
2319
+ * keeps today's result. Pass one to get a defined order — which is also what makes a page budget meaningful,
2320
+ * since relevance ranking can shift under an unchanged upstream.
2321
+ */
2242
2322
  async searchMyIssues(provider, token, options, cancellation) {
2243
2323
  let search = options?.search?.trim() ?? '';
2244
2324
  if (options?.user) {
@@ -2248,7 +2328,10 @@ export class GitHubApi {
2248
2328
  const repo = ' repo:';
2249
2329
  search += `${repo}${options.repos.join(repo)}`;
2250
2330
  }
2251
- const baseFilters = 'type:issue is:open archived:false';
2331
+ // A requested sort goes through the same table `searchIssuesPage` uses, so the two GitHub issue reads can't
2332
+ // diverge the first time a key is added. Omitted appends nothing — see this method's contract above.
2333
+ const sortQualifier = toGitHubIssueSortQualifier(options?.sort);
2334
+ const baseFilters = ['type:issue is:open archived:false', sortQualifier].filter(Boolean).join(' ');
2252
2335
  // `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
2253
2336
  // (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
2254
2337
  // user-relative by definition, so an all-assignees read still only surfaces the current user's authored
@@ -2283,25 +2366,45 @@ export class GitHubApi {
2283
2366
  if (requested.authored) {
2284
2367
  searches.push({ alias: 'authored', query: `${search} ${baseFilters} author:@me`.trim() });
2285
2368
  }
2286
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
2369
+ // Field by field, like `searchIssuesPage`: `options` also carries `repos`/`includeAllAssignees`/`categories`,
2370
+ // already folded into `searches[].query` above and undeclared by the callee.
2371
+ return this.searchIssuesByAlias(provider, token, searches, {
2372
+ baseUrl: options?.baseUrl,
2373
+ avatarSize: options?.avatarSize,
2374
+ includeBody: options?.includeBody,
2375
+ cursor: options?.cursor,
2376
+ sort: options?.sort,
2377
+ // This read emitted no `sort:` qualifier at all before ordering existed, so a cursor with no
2378
+ // recorded key came out of a relevance-ordered walk.
2379
+ legacySort: unsortedCursorSort,
2380
+ }, cancellation);
2287
2381
  }
2288
2382
  /**
2289
2383
  * The filtered issue search: issues matching structured criteria over a repository/org scope, with no forced
2290
2384
  * relationship to the current user. The issue counterpart of {@link searchMyPullRequestsPage}, and distinct
2291
2385
  * from {@link searchMyIssues}, which is permanently bound to `@me`.
2292
2386
  *
2293
- * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first). A
2294
- * consumer's "show the N most recent" policy at GitHub's result ceiling is only correct under a guaranteed
2295
- * order, and an option would let a caller pick relevance order and then truncate to an arbitrary subset.
2387
+ * Ordering is `criteria.sort`, defaulting to most-recently-updated-first the order this read served before
2388
+ * ordering was an option, so an omitted `sort` emits the identical query. What is NOT optional is that SOME
2389
+ * order is always requested: without one GitHub answers in relevance order, and at the result ceiling that
2390
+ * makes which rows are reachable a function of GitHub's ranking rather than of the request. A key GitHub can't
2391
+ * express (`closed`, `priority`, …) is refused by the facade before the request, not silently downgraded.
2392
+ *
2393
+ * With more than one relationship the page is a UNION of several searches, each ordered by the provider; the
2394
+ * merged page is re-sorted here so the whole page honors the requested key. Across pages the order is still
2395
+ * per-alias — see {@link searchIssuesByAlias}.
2296
2396
  *
2297
2397
  * Each requested relationship becomes its own aliased search, unioned and deduped by url; with none, a single
2298
2398
  * search runs over the scope alone. `criteria.text` and the other free-form values are sanitized so user input
2299
2399
  * cannot inject a qualifier and re-scope the search — see {@link toGitHubIssueSearchQualifiers}.
2300
2400
  */
2301
2401
  async searchIssuesPage(provider, token, options, cancellation) {
2402
+ // Resolved once: the emitted qualifier, the merged page's comparator and the cursor's fingerprint must all
2403
+ // be the same key, which is what `effectiveIssueSort` exists to guarantee.
2404
+ const sort = effectiveIssueSort(options?.criteria?.sort);
2302
2405
  const base = [
2303
2406
  ...toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos),
2304
- ...toGitHubIssueSearchQualifiers(options?.criteria),
2407
+ ...toGitHubIssueSearchQualifiers(options?.criteria, sort),
2305
2408
  ].join(' ');
2306
2409
  // One aliased search per relationship, OR-ed by union. They can't be one query: GitHub AND-s qualifiers,
2307
2410
  // so `author:@me assignee:@me` would return the intersection — issues the user both opened and is assigned
@@ -2313,7 +2416,21 @@ export class GitHubApi {
2313
2416
  query: `${base} ${gitHubIssueSearchRelationships[r].qualifier}`.trim(),
2314
2417
  }))
2315
2418
  : [{ alias: 'matched', query: base }];
2316
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
2419
+ // Forwarded field by field rather than spread: `options` also carries `repos`/`org`/`criteria`, which are
2420
+ // already baked into `searches[].query` above and which the callee declares nothing about. `sort` is the
2421
+ // EFFECTIVE key, since the merged page and the cursor's fingerprint must both use the one the query used.
2422
+ return this.searchIssuesByAlias(provider, token, searches, {
2423
+ baseUrl: options?.baseUrl,
2424
+ avatarSize: options?.avatarSize,
2425
+ includeBody: options?.includeBody,
2426
+ cursor: options?.cursor,
2427
+ pageSize: options?.pageSize,
2428
+ sort: sort,
2429
+ // This read has always emitted `sort:updated`, which is `defaultIssueSort` — so a cursor with no
2430
+ // recorded key came out of a walk under exactly that key, and only a caller asking for a
2431
+ // different one has to restart.
2432
+ legacySort: defaultIssueSort,
2433
+ }, cancellation);
2317
2434
  }
2318
2435
  /**
2319
2436
  * Counts issues for several scopes in ONE request, transferring no issues at all — each scope is an aliased
@@ -2332,9 +2449,12 @@ export class GitHubApi {
2332
2449
  if (scopes.length === 0)
2333
2450
  return [];
2334
2451
  const queries = scopes.map(s => {
2452
+ // The same resolved key the search would use, so the count previews the query it previews. Ordering
2453
+ // cannot change a total, but emitting a DIFFERENT qualifier string than the search does would break the
2454
+ // parity this probe is for.
2335
2455
  const qualifiers = [
2336
2456
  ...toGitHubIssueSearchScopeQualifiers(s.org, s.repos),
2337
- ...toGitHubIssueSearchQualifiers(s.criteria),
2457
+ ...toGitHubIssueSearchQualifiers(s.criteria, effectiveIssueSort(s.criteria?.sort)),
2338
2458
  ];
2339
2459
  // A relationship set is OR-ed across searches, which a single count can't express — the facade splits
2340
2460
  // such a scope into one count per relationship before calling, so at most one is present here.
@@ -2370,6 +2490,64 @@ export class GitHubApi {
2370
2490
  throw this.handleException(ex, provider, scope);
2371
2491
  }
2372
2492
  }
2493
+ /**
2494
+ * The PR twin of {@link countIssues}: counts pull requests for several scopes in ONE request via aliased
2495
+ * `search` fields selecting only `issueCount` with `first: 0`. Same positional/undefined contract as the issue
2496
+ * count.
2497
+ *
2498
+ * The one difference is states. {@link toGitHubPullRequestSearchFacets} fans a scope's states out into one
2499
+ * `search` each, so this reports the count the SAME way {@link searchPullRequestsPage} reports its total — the
2500
+ * LARGEST facet's `issueCount`, not their sum: the result ceiling applies per search, so the max is what
2501
+ * `exceedsProviderLimit` compares against, and summing would claim a total the read never surfaces. Relationships
2502
+ * are OR-ed and can't be a single count; the facade refuses a multi-relationship scope, so at most one is here.
2503
+ */
2504
+ async countPullRequests(provider, token, scopes, options, cancellation) {
2505
+ const scope = getScopedLogger();
2506
+ if (scopes.length === 0)
2507
+ return [];
2508
+ // Each scope expands to one query string per state facet (all sharing its single relationship, which the
2509
+ // facade guarantees). A scope's count is the MAX across its facets — mirroring searchPullRequestsPage's
2510
+ // totalCount — so the aliases stay grouped by scope and are reduced after the response.
2511
+ const scopeQueries = scopes.map(s => {
2512
+ const scopeQualifiers = toGitHubIssueSearchScopeQualifiers(s.org, s.repos);
2513
+ return toGitHubPullRequestSearchFacets(s.criteria).map(f => [...scopeQualifiers, ...f.qualifiers].join(' '));
2514
+ });
2515
+ // Aliases are positional and generated (`s${scope}f${facet}`): a caller's key is arbitrary text and would
2516
+ // break the document, so results are reduced back to one count per scope by index.
2517
+ const aliased = scopeQueries.flatMap((queries, si) => queries.map((query, fi) => ({ alias: `s${si}f${fi}`, query: query })));
2518
+ const params = aliased.map(a => `$${a.alias}: String!`).join('\n\t\t\t\t');
2519
+ // `first: 0` is what makes this cheap — `issueCount` alone, no nodes over the wire.
2520
+ const fields = aliased
2521
+ .map(a => `${a.alias}: search(query: $${a.alias}, type: ISSUE, first: 0) { issueCount }`)
2522
+ .join('\n\t\t\t\t');
2523
+ const query = `query countPullRequests(
2524
+ ${params}
2525
+ ) {
2526
+ ${fields}
2527
+ }`;
2528
+ const variables = { baseUrl: options?.baseUrl };
2529
+ for (const a of aliased) {
2530
+ variables[a.alias] = a.query;
2531
+ }
2532
+ try {
2533
+ const rsp = await this.graphql(provider, token, query, variables, scope, cancellation);
2534
+ if (rsp == null)
2535
+ return scopes.map(() => undefined);
2536
+ return scopeQueries.map((queries, si) => {
2537
+ let max;
2538
+ for (let fi = 0; fi < queries.length; fi++) {
2539
+ const count = rsp[`s${si}f${fi}`]?.issueCount;
2540
+ if (count != null) {
2541
+ max = max == null ? count : Math.max(max, count);
2542
+ }
2543
+ }
2544
+ return max;
2545
+ });
2546
+ }
2547
+ catch (ex) {
2548
+ throw this.handleException(ex, provider, scope);
2549
+ }
2550
+ }
2373
2551
  /**
2374
2552
  * The aliased-search engine behind every GitHub issue search: one GraphQL request carrying N independently
2375
2553
  * cursored `search` fields, `@include`-gated so an exhausted or unrequested one costs nothing.
@@ -2384,8 +2562,17 @@ export class GitHubApi {
2384
2562
  * {@link searchMyIssues} is one configuration of it (its three `@me` categories), and its alias names are
2385
2563
  * that read's published cursor keys.
2386
2564
  *
2387
- * `searches` must have unique aliases, each a valid GraphQL name that is neither `page` nor `truncated`
2388
- * the composite cursor keys aliases at its top level, alongside those two reserved fields.
2565
+ * `searches` must have unique aliases, each a valid GraphQL name that is none of `page`, `truncated` or
2566
+ * `sort` — the composite cursor keys aliases at its top level, alongside those three reserved fields.
2567
+ *
2568
+ * `sort` is the order the caller asked for, which this does two things with. Each alias comes back ordered by
2569
+ * it (the qualifier is already in `searches[].query`), but the UNION of several aliases is not, so the merged
2570
+ * page is re-sorted here; and the key is recorded in the cursor, so a continuation that changed it THROWS
2571
+ * rather than serving a sequence with gaps and repeats. Omitted means the caller asked for no order at all
2572
+ * ({@link searchMyIssues}'s default), which re-sorts nothing and pins nothing.
2573
+ *
2574
+ * `legacySort` is the order the calling read produced BEFORE this field existed, and is what a cursor with no
2575
+ * recorded key is compared against — such a cursor is not of unknown order, it is of that read's old one.
2389
2576
  */
2390
2577
  async searchIssuesByAlias(provider, token, searches, options, cancellation) {
2391
2578
  const scope = getScopedLogger();
@@ -2394,10 +2581,19 @@ export class GitHubApi {
2394
2581
  // by a cursor string that reads back as page 1, restarting the walk with no error and no truncation flag.
2395
2582
  // Cheap to check, and it fails at the one call that introduced the collision rather than in a consumer's
2396
2583
  // persisted cursor.
2397
- const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated');
2584
+ const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated' || s.alias === 'sort');
2398
2585
  if (reserved.length > 0) {
2399
2586
  throw new Error(`Issue search alias(es) ${reserved.map(s => `'${s.alias}'`).join(', ')} collide with the composite cursor's reserved keys`);
2400
2587
  }
2588
+ // A key GitHub cannot express emits no `sort:` qualifier, so each alias would come back in RELEVANCE order
2589
+ // while the union below is sorted by that key and the cursor sealed under it: an arbitrary subset,
2590
+ // presented as ordered, resumable only into more of the same. Refused here rather than downgraded, which
2591
+ // is the rule the whole feature is built on. Unreachable through the facade — no `supportedIssueSorts`
2592
+ // table declares a key without a qualifier — so this guards the direct callers of the two public reads,
2593
+ // where `title` is expressible enough to have a comparator and not enough to be a GitHub search qualifier.
2594
+ if (options.sort != null && toGitHubIssueSortQualifier(options.sort) == null) {
2595
+ throw new Error(`GitHub cannot order an issue search by '${options.sort}'`);
2596
+ }
2401
2597
  let cursor;
2402
2598
  if (options?.cursor != null) {
2403
2599
  try {
@@ -2405,6 +2601,28 @@ export class GitHubApi {
2405
2601
  }
2406
2602
  catch { }
2407
2603
  }
2604
+ // The order this request is being made under, as the cursor records it.
2605
+ const requestedSort = options.sort ?? unsortedCursorSort;
2606
+ // A cursor produced under a DIFFERENT order can't be resumed: every alias would continue from a position in
2607
+ // a differently-ordered result set, so the continuation re-emits rows already seen and skips rows never
2608
+ // seen. REFUSED rather than silently restarted from page 1, because a restart cannot be reported honestly
2609
+ // from here: this read is cursor-only, so `resolveCurrentPage` has no page of its own to trust and echoes
2610
+ // the `page` the caller supplied alongside the cursor — page 1's rows would be published as page N, which
2611
+ // is the very confusion the fingerprint exists to prevent. Refusing surfaces a warning + `fetchFailed`, and
2612
+ // the remedy ("drop the cursor") is the caller's to apply.
2613
+ //
2614
+ // A cursor with NO recorded sort predates this field — which is not the same as being of unknown order.
2615
+ // Each read produced exactly one order before ordering was an option (`sort:updated` for the filtered
2616
+ // search, relevance for `searchMyIssues`), so an absent key reads as THAT one, `legacySort`. Compared
2617
+ // rather than waved through: the facade now resolves an omitted key to `defaultIssueSort`, so the
2618
+ // account-wide read's query gained a `sort:updated` qualifier it did not have, and resuming a
2619
+ // relevance-ordered cursor inside it advances each alias through a re-ordered result set — the gaps and
2620
+ // repeats this check exists to prevent, arriving through the very case meant to keep working. A cursor
2621
+ // whose implied key matches the request still resumes, and is sealed with the current one.
2622
+ const cursorSort = cursor?.sort ?? options.legacySort;
2623
+ if (cursor != null && cursorSort !== requestedSort) {
2624
+ throw new Error(`Issue search cursor was produced under sort '${cursorSort}' but '${requestedSort}' was requested; restart the read without a cursor`);
2625
+ }
2408
2626
  const page = Math.max(1, Math.trunc(cursor?.page ?? 1));
2409
2627
  // A slot is a continuation string, `null` (exhausted), or absent. Anything else came from a malformed or
2410
2628
  // foreign cursor, and is read as absent rather than threaded back into the request as a continuation.
@@ -2481,11 +2699,32 @@ export class GitHubApi {
2481
2699
  }
2482
2700
  // Dedupe by `url`, not `IssueShape.id`: for some providers `id` is a per-repository number, so an
2483
2701
  // id-keyed map would collapse distinct issues across repositories.
2484
- const results = uniqueBy(issues, r => r.url, (original, _current) => original);
2702
+ const deduped = [
2703
+ ...uniqueBy(issues, r => r.url, (original, _current) => original),
2704
+ ];
2705
+ // Each alias arrived ordered by the server; their concatenation is not, so the merged page is ordered
2706
+ // here. AFTER the dedupe, not before, and that ordering is load-bearing: the alias order is also the
2707
+ // dedupe's precedence (an issue both assigned to and authored by the user surfaces as the assigned one,
2708
+ // per `searchMyIssues`), and sorting first would hand `uniqueBy` a different first occurrence and
2709
+ // silently change which copy wins. The pull-request path sorts BEFORE its dedupe because its facets
2710
+ // carry no such precedence — the difference is deliberate, not an inconsistency to tidy up.
2711
+ //
2712
+ // A comparator is always available for a key GitHub declares (`created`/`updated`/`comments`/
2713
+ // `reactions` are all on `IssueShape`), so `undefined` here means the capability table has outrun this
2714
+ // read; leave the provider's per-alias order rather than inventing one.
2715
+ //
2716
+ // Counted over `active`, not `searches`: continuations exhaust aliases one at a time, so a later page of
2717
+ // a three-category walk can come from ONE surviving search — already ordered by the server. Re-sorting
2718
+ // it could only reproduce that order, while hiding a provider that ignored the qualifier.
2719
+ const comparator = options?.sort != null ? getIssueComparator(options.sort) : undefined;
2720
+ if (comparator != null && active.length > 1) {
2721
+ deduped.sort(comparator);
2722
+ }
2485
2723
  // Every alias gets a slot, so an inactive one keeps its `null` and stays out of the next request. A
2486
2724
  // missing slot would be read as "never requested", which for a `searches` set that still lists it
2487
2725
  // would restart it from its first page.
2488
- const next = { page: page + 1 };
2726
+ // The order is pinned on the way out too, so the next round can refuse a changed key (see above).
2727
+ const next = { page: page + 1, sort: requestedSort };
2489
2728
  let hasMore = false;
2490
2729
  let continuationMissing = false;
2491
2730
  let maxIssueCount = 0;
@@ -2507,7 +2746,7 @@ export class GitHubApi {
2507
2746
  const truncated = cursor?.truncated === true || maxIssueCount > githubSearchResultLimit || continuationMissing;
2508
2747
  next.truncated = truncated || undefined;
2509
2748
  return {
2510
- values: [...results],
2749
+ values: deduped,
2511
2750
  cursor: hasMore ? JSON.stringify(next) : undefined,
2512
2751
  hasMore: hasMore,
2513
2752
  page: page,
@@ -2524,8 +2763,11 @@ export class GitHubApi {
2524
2763
  * every active relationship × state facet, so one HTTP request serves one page even when the logical search is
2525
2764
  * a union. The cursor preserves each facet's continuation plus the positional page.
2526
2765
  *
2527
- * Ordering is always most-recently-updated-first, and user text is sanitized before it reaches the provider
2528
- * query. See {@link toGitHubPullRequestSearchFacets}.
2766
+ * Ordering is `criteria.sort`, defaulting to most-recently-updated-first the order this read served before it
2767
+ * was expressible. It is requested of the provider AND re-applied to the merged page, because the page is a
2768
+ * union of facets and no per-facet server order describes it. The sort is part of the cursor fingerprint, so
2769
+ * changing it invalidates a threaded cursor exactly as changing the text or the scope does. User text is
2770
+ * sanitized before it reaches the provider query. See {@link toGitHubPullRequestSearchFacets}.
2529
2771
  */
2530
2772
  async searchPullRequestsPage(provider, token, options, cancellation) {
2531
2773
  const scope = getScopedLogger();
@@ -2636,7 +2878,15 @@ export class GitHubApi {
2636
2878
  }
2637
2879
  }
2638
2880
  }
2639
- pullRequests.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime());
2881
+ // The PR path re-sorts the merged page rather than trusting the per-facet server order — GitHub's
2882
+ // server-side PR sort has been unreliable (the per-branch path re-sorts for the same reason), and the
2883
+ // union of several facets is unordered regardless. The key was validated against
2884
+ // `githubPullRequestSearchCapabilities.sorts` upstream, so its comparator is always defined for a PR
2885
+ // shape; the guard only guards the unreachable case rather than inventing an order for it.
2886
+ const comparator = getPullRequestComparator(options?.criteria?.sort ?? defaultPullRequestSort);
2887
+ if (comparator != null) {
2888
+ pullRequests.sort(comparator);
2889
+ }
2640
2890
  const values = [
2641
2891
  ...uniqueBy(pullRequests, pr => pr.url, (original, _current) => original),
2642
2892
  ];
@@ -3083,6 +3333,9 @@ __decorate([
3083
3333
  __decorate([
3084
3334
  trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3085
3335
  ], GitHubApi.prototype, "countIssues", null);
3336
+ __decorate([
3337
+ trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3338
+ ], GitHubApi.prototype, "countPullRequests", null);
3086
3339
  __decorate([
3087
3340
  trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3088
3341
  ], GitHubApi.prototype, "searchPullRequestsPage", null);