@gitkraken/core-gitlens 0.5.107 → 0.5.109

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 (145) hide show
  1. package/CHANGELOG.md +24 -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/utils/issue.utils.d.ts +41 -1
  7. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  8. package/dist/git/utils/issue.utils.js +80 -0
  9. package/dist/git/utils/issue.utils.js.map +1 -1
  10. package/dist/plus/git-github/api/github.d.ts +33 -6
  11. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  12. package/dist/plus/git-github/api/github.js +137 -15
  13. package/dist/plus/git-github/api/github.js.map +1 -1
  14. package/dist/plus/git-github/api/issueSearchQuery.d.ts +49 -5
  15. package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -1
  16. package/dist/plus/git-github/api/issueSearchQuery.js +64 -6
  17. package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -1
  18. package/dist/plus/integrations/collectionMetadata.d.ts +18 -0
  19. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  20. package/dist/plus/integrations/collectionMetadata.js +24 -0
  21. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  22. package/dist/plus/integrations/index.d.ts +1 -1
  23. package/dist/plus/integrations/index.d.ts.map +1 -1
  24. package/dist/plus/integrations/index.js.map +1 -1
  25. package/dist/plus/integrations/integrationService.d.ts +15 -9
  26. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  27. package/dist/plus/integrations/integrationService.js.map +1 -1
  28. package/dist/plus/integrations/manager.d.ts +115 -26
  29. package/dist/plus/integrations/manager.d.ts.map +1 -1
  30. package/dist/plus/integrations/models/gitHostIntegration.d.ts +6 -31
  31. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  32. package/dist/plus/integrations/models/gitHostIntegration.js +11 -1
  33. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  34. package/dist/plus/integrations/models/issueReads.d.ts +63 -1
  35. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -1
  36. package/dist/plus/integrations/models/issuesIntegration.d.ts +7 -21
  37. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  38. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  39. package/dist/plus/integrations/providerFilters.d.ts +1 -1
  40. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  41. package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
  42. package/dist/plus/integrations/providers/azureDevOps.js +1 -0
  43. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  44. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  45. package/dist/plus/integrations/providers/github.js +1 -0
  46. package/dist/plus/integrations/providers/github.js.map +1 -1
  47. package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
  48. package/dist/plus/integrations/providers/gitlab.js +1 -0
  49. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  50. package/dist/plus/integrations/providers/issueSorts.d.ts +36 -0
  51. package/dist/plus/integrations/providers/issueSorts.d.ts.map +1 -0
  52. package/dist/plus/integrations/providers/issueSorts.js +76 -0
  53. package/dist/plus/integrations/providers/issueSorts.js.map +1 -0
  54. package/dist/plus/integrations/providers/jira.d.ts +3 -9
  55. package/dist/plus/integrations/providers/jira.d.ts.map +1 -1
  56. package/dist/plus/integrations/providers/jira.js +5 -1
  57. package/dist/plus/integrations/providers/jira.js.map +1 -1
  58. package/dist/plus/integrations/providers/linear.d.ts +24 -9
  59. package/dist/plus/integrations/providers/linear.d.ts.map +1 -1
  60. package/dist/plus/integrations/providers/linear.js +63 -5
  61. package/dist/plus/integrations/providers/linear.js.map +1 -1
  62. package/dist/plus/integrations/providers/models.d.ts +63 -7
  63. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  64. package/dist/plus/integrations/providers/models.js +55 -0
  65. package/dist/plus/integrations/providers/models.js.map +1 -1
  66. package/dist/plus/integrations/providers/providersApi.d.ts +18 -3
  67. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  68. package/dist/plus/integrations/providers/providersApi.js +24 -7
  69. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  70. package/dist/plus/integrations/providers/trello.d.ts +3 -9
  71. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  72. package/dist/plus/integrations/providers/trello.js +1 -0
  73. package/dist/plus/integrations/providers/trello.js.map +1 -1
  74. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  75. package/dist/plus/integrations/providers/utils/providerPaging.js +5 -1
  76. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  77. package/dist/plus/integrations/reads/broaden.d.ts +6 -0
  78. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  79. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  80. package/dist/plus/integrations/reads/counts.d.ts +8 -1
  81. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  82. package/dist/plus/integrations/reads/counts.js.map +1 -1
  83. package/dist/plus/integrations/reads/filters.d.ts +59 -9
  84. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  85. package/dist/plus/integrations/reads/filters.js +17 -0
  86. package/dist/plus/integrations/reads/filters.js.map +1 -1
  87. package/dist/plus/integrations/reads/issueTracker.d.ts +18 -1
  88. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  89. package/dist/plus/integrations/reads/issueTracker.js +40 -3
  90. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  91. package/dist/plus/integrations/reads/issues.d.ts +27 -3
  92. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  93. package/dist/plus/integrations/reads/issues.js +223 -123
  94. package/dist/plus/integrations/reads/issues.js.map +1 -1
  95. package/dist/plus/integrations/reads/ordering.d.ts +101 -0
  96. package/dist/plus/integrations/reads/ordering.d.ts.map +1 -0
  97. package/dist/plus/integrations/reads/ordering.js +48 -0
  98. package/dist/plus/integrations/reads/ordering.js.map +1 -0
  99. package/dist/plus/integrations/reads/searchIssues.d.ts +7 -2
  100. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
  101. package/dist/plus/integrations/reads/searchIssues.js +9 -3
  102. package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
  103. package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -1
  104. package/dist/plus/integrations/reads/sweeps.js +120 -56
  105. package/dist/plus/integrations/reads/sweeps.js.map +1 -1
  106. package/dist/plus/integrations/reads/warnings.d.ts +34 -2
  107. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  108. package/dist/plus/integrations/reads/warnings.js +47 -2
  109. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  110. package/dist/plus/integrations/results.d.ts +10 -0
  111. package/dist/plus/integrations/results.d.ts.map +1 -1
  112. package/dist/plus/integrations/results.js.map +1 -1
  113. package/package.json +2 -2
  114. package/src/git/models/issue.ts +73 -0
  115. package/src/git/utils/issue.utils.ts +91 -1
  116. package/src/plus/git-github/api/github.ts +180 -20
  117. package/src/plus/git-github/api/issueSearchQuery.ts +67 -7
  118. package/src/plus/integrations/collectionMetadata.ts +24 -0
  119. package/src/plus/integrations/index.ts +1 -0
  120. package/src/plus/integrations/integrationService.ts +15 -10
  121. package/src/plus/integrations/manager.ts +116 -31
  122. package/src/plus/integrations/models/gitHostIntegration.ts +18 -31
  123. package/src/plus/integrations/models/issueReads.ts +65 -1
  124. package/src/plus/integrations/models/issuesIntegration.ts +7 -6
  125. package/src/plus/integrations/providerFilters.ts +2 -0
  126. package/src/plus/integrations/providers/azureDevOps.ts +1 -0
  127. package/src/plus/integrations/providers/github.ts +1 -0
  128. package/src/plus/integrations/providers/gitlab.ts +1 -0
  129. package/src/plus/integrations/providers/issueSorts.ts +89 -0
  130. package/src/plus/integrations/providers/jira.ts +9 -3
  131. package/src/plus/integrations/providers/linear.ts +77 -8
  132. package/src/plus/integrations/providers/models.ts +122 -5
  133. package/src/plus/integrations/providers/providersApi.ts +51 -13
  134. package/src/plus/integrations/providers/trello.ts +4 -3
  135. package/src/plus/integrations/providers/utils/providerPaging.ts +6 -1
  136. package/src/plus/integrations/reads/broaden.ts +6 -0
  137. package/src/plus/integrations/reads/counts.ts +8 -1
  138. package/src/plus/integrations/reads/filters.ts +70 -8
  139. package/src/plus/integrations/reads/issueTracker.ts +78 -4
  140. package/src/plus/integrations/reads/issues.ts +386 -194
  141. package/src/plus/integrations/reads/ordering.ts +124 -0
  142. package/src/plus/integrations/reads/searchIssues.ts +15 -3
  143. package/src/plus/integrations/reads/sweeps.ts +150 -76
  144. package/src/plus/integrations/reads/warnings.ts +72 -1
  145. package/src/plus/integrations/results.ts +10 -0
@@ -8,8 +8,10 @@ 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 { defaultIssueSort } from '../../../git/models/issue.js';
11
12
  import { 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';
13
15
  import { createRevisionRange, getRevisionRangeParts, isRevisionRange, isSha, } from '../../../git/utils/revision.utils.js';
14
16
  import { chunk } from '../../../utils/array.js';
15
17
  import { base64 } from '../../../utils/base64.js';
@@ -24,9 +26,18 @@ import { parseUri } from '../../../utils/uri.js';
24
26
  import { fromString, satisfies } from '../../../utils/version.js';
25
27
  import { fromGitHubIssue, fromGitHubIssueOrPullRequestState, fromGitHubPullRequest, fromGitHubPullRequestLite, } from '../models.js';
26
28
  import { githubSearchResultLimit } from './config.js';
27
- import { gitHubIssueSearchRelationships, toGitHubIssueSearchQualifiers, toGitHubIssueSearchScopeQualifiers, } from './issueSearchQuery.js';
29
+ import { gitHubIssueSearchRelationships, toGitHubIssueSearchQualifiers, toGitHubIssueSearchScopeQualifiers, toGitHubIssueSortQualifier, } from './issueSearchQuery.js';
28
30
  import { toGitHubPullRequestSearchFacets } from './pullRequestSearchQuery.js';
29
31
  const emptyPagedResult = Object.freeze({ values: [] });
32
+ /**
33
+ * What an issue-search cursor records when the caller asked for no ordering at all.
34
+ *
35
+ * A sentinel rather than an omitted field, because omitted already means something else and more important: a
36
+ * cursor persisted before ordering existed. Distinguishing the two is what lets an old cursor keep resuming while
37
+ * a genuine change from unordered to ordered is still refused. Not an `IssueSorting`, so it can never collide
38
+ * with one.
39
+ */
40
+ const unsortedCursorSort = 'unsorted';
30
41
  const emptyBlameResult = Object.freeze({ ranges: [] });
31
42
  // Transient gateway/network failures (e.g. an upstream `502 Bad Gateway`) are worth a few quick
32
43
  // retries before surfacing to the caller. octokit provides no built-in retry for the standalone
@@ -2239,6 +2250,16 @@ export class GitHubApi {
2239
2250
  async searchMyPullRequests(provider, token, options, cancellation) {
2240
2251
  return (await this.searchMyPullRequestsPage(provider, token, options, cancellation)).values;
2241
2252
  }
2253
+ /**
2254
+ * The current user's issues: authored ∪ assigned ∪ mentioned, each its own aliased search behind one composite
2255
+ * cursor. Bound to `@me` by construction, unlike {@link searchIssuesPage}.
2256
+ *
2257
+ * Ordering is OPT-IN here, and that asymmetry with {@link searchIssuesPage} is deliberate: this read has never
2258
+ * requested a sort, so GitHub has always answered it in relevance order. Emitting a default would change which
2259
+ * issues its already-shipped consumers see, so an omitted `sort` still emits no `sort:` qualifier at all and
2260
+ * keeps today's result. Pass one to get a defined order — which is also what makes a page budget meaningful,
2261
+ * since relevance ranking can shift under an unchanged upstream.
2262
+ */
2242
2263
  async searchMyIssues(provider, token, options, cancellation) {
2243
2264
  let search = options?.search?.trim() ?? '';
2244
2265
  if (options?.user) {
@@ -2248,7 +2269,10 @@ export class GitHubApi {
2248
2269
  const repo = ' repo:';
2249
2270
  search += `${repo}${options.repos.join(repo)}`;
2250
2271
  }
2251
- const baseFilters = 'type:issue is:open archived:false';
2272
+ // A requested sort goes through the same table `searchIssuesPage` uses, so the two GitHub issue reads can't
2273
+ // diverge the first time a key is added. Omitted appends nothing — see this method's contract above.
2274
+ const sortQualifier = toGitHubIssueSortQualifier(options?.sort);
2275
+ const baseFilters = ['type:issue is:open archived:false', sortQualifier].filter(Boolean).join(' ');
2252
2276
  // `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
2253
2277
  // (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
2254
2278
  // user-relative by definition, so an all-assignees read still only surfaces the current user's authored
@@ -2283,25 +2307,45 @@ export class GitHubApi {
2283
2307
  if (requested.authored) {
2284
2308
  searches.push({ alias: 'authored', query: `${search} ${baseFilters} author:@me`.trim() });
2285
2309
  }
2286
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
2310
+ // Field by field, like `searchIssuesPage`: `options` also carries `repos`/`includeAllAssignees`/`categories`,
2311
+ // already folded into `searches[].query` above and undeclared by the callee.
2312
+ return this.searchIssuesByAlias(provider, token, searches, {
2313
+ baseUrl: options?.baseUrl,
2314
+ avatarSize: options?.avatarSize,
2315
+ includeBody: options?.includeBody,
2316
+ cursor: options?.cursor,
2317
+ sort: options?.sort,
2318
+ // This read emitted no `sort:` qualifier at all before ordering existed, so a cursor with no
2319
+ // recorded key came out of a relevance-ordered walk.
2320
+ legacySort: unsortedCursorSort,
2321
+ }, cancellation);
2287
2322
  }
2288
2323
  /**
2289
2324
  * The filtered issue search: issues matching structured criteria over a repository/org scope, with no forced
2290
2325
  * relationship to the current user. The issue counterpart of {@link searchMyPullRequestsPage}, and distinct
2291
2326
  * from {@link searchMyIssues}, which is permanently bound to `@me`.
2292
2327
  *
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.
2328
+ * Ordering is `criteria.sort`, defaulting to most-recently-updated-first the order this read served before
2329
+ * ordering was an option, so an omitted `sort` emits the identical query. What is NOT optional is that SOME
2330
+ * order is always requested: without one GitHub answers in relevance order, and at the result ceiling that
2331
+ * makes which rows are reachable a function of GitHub's ranking rather than of the request. A key GitHub can't
2332
+ * express (`closed`, `priority`, …) is refused by the facade before the request, not silently downgraded.
2333
+ *
2334
+ * With more than one relationship the page is a UNION of several searches, each ordered by the provider; the
2335
+ * merged page is re-sorted here so the whole page honors the requested key. Across pages the order is still
2336
+ * per-alias — see {@link searchIssuesByAlias}.
2296
2337
  *
2297
2338
  * Each requested relationship becomes its own aliased search, unioned and deduped by url; with none, a single
2298
2339
  * search runs over the scope alone. `criteria.text` and the other free-form values are sanitized so user input
2299
2340
  * cannot inject a qualifier and re-scope the search — see {@link toGitHubIssueSearchQualifiers}.
2300
2341
  */
2301
2342
  async searchIssuesPage(provider, token, options, cancellation) {
2343
+ // Resolved once: the emitted qualifier, the merged page's comparator and the cursor's fingerprint must all
2344
+ // be the same key, which is what `effectiveIssueSort` exists to guarantee.
2345
+ const sort = effectiveIssueSort(options?.criteria?.sort);
2302
2346
  const base = [
2303
2347
  ...toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos),
2304
- ...toGitHubIssueSearchQualifiers(options?.criteria),
2348
+ ...toGitHubIssueSearchQualifiers(options?.criteria, sort),
2305
2349
  ].join(' ');
2306
2350
  // One aliased search per relationship, OR-ed by union. They can't be one query: GitHub AND-s qualifiers,
2307
2351
  // so `author:@me assignee:@me` would return the intersection — issues the user both opened and is assigned
@@ -2313,7 +2357,21 @@ export class GitHubApi {
2313
2357
  query: `${base} ${gitHubIssueSearchRelationships[r].qualifier}`.trim(),
2314
2358
  }))
2315
2359
  : [{ alias: 'matched', query: base }];
2316
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
2360
+ // Forwarded field by field rather than spread: `options` also carries `repos`/`org`/`criteria`, which are
2361
+ // already baked into `searches[].query` above and which the callee declares nothing about. `sort` is the
2362
+ // EFFECTIVE key, since the merged page and the cursor's fingerprint must both use the one the query used.
2363
+ return this.searchIssuesByAlias(provider, token, searches, {
2364
+ baseUrl: options?.baseUrl,
2365
+ avatarSize: options?.avatarSize,
2366
+ includeBody: options?.includeBody,
2367
+ cursor: options?.cursor,
2368
+ pageSize: options?.pageSize,
2369
+ sort: sort,
2370
+ // This read has always emitted `sort:updated`, which is `defaultIssueSort` — so a cursor with no
2371
+ // recorded key came out of a walk under exactly that key, and only a caller asking for a
2372
+ // different one has to restart.
2373
+ legacySort: defaultIssueSort,
2374
+ }, cancellation);
2317
2375
  }
2318
2376
  /**
2319
2377
  * Counts issues for several scopes in ONE request, transferring no issues at all — each scope is an aliased
@@ -2332,9 +2390,12 @@ export class GitHubApi {
2332
2390
  if (scopes.length === 0)
2333
2391
  return [];
2334
2392
  const queries = scopes.map(s => {
2393
+ // The same resolved key the search would use, so the count previews the query it previews. Ordering
2394
+ // cannot change a total, but emitting a DIFFERENT qualifier string than the search does would break the
2395
+ // parity this probe is for.
2335
2396
  const qualifiers = [
2336
2397
  ...toGitHubIssueSearchScopeQualifiers(s.org, s.repos),
2337
- ...toGitHubIssueSearchQualifiers(s.criteria),
2398
+ ...toGitHubIssueSearchQualifiers(s.criteria, effectiveIssueSort(s.criteria?.sort)),
2338
2399
  ];
2339
2400
  // A relationship set is OR-ed across searches, which a single count can't express — the facade splits
2340
2401
  // such a scope into one count per relationship before calling, so at most one is present here.
@@ -2384,8 +2445,17 @@ export class GitHubApi {
2384
2445
  * {@link searchMyIssues} is one configuration of it (its three `@me` categories), and its alias names are
2385
2446
  * that read's published cursor keys.
2386
2447
  *
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.
2448
+ * `searches` must have unique aliases, each a valid GraphQL name that is none of `page`, `truncated` or
2449
+ * `sort` — the composite cursor keys aliases at its top level, alongside those three reserved fields.
2450
+ *
2451
+ * `sort` is the order the caller asked for, which this does two things with. Each alias comes back ordered by
2452
+ * it (the qualifier is already in `searches[].query`), but the UNION of several aliases is not, so the merged
2453
+ * page is re-sorted here; and the key is recorded in the cursor, so a continuation that changed it THROWS
2454
+ * rather than serving a sequence with gaps and repeats. Omitted means the caller asked for no order at all
2455
+ * ({@link searchMyIssues}'s default), which re-sorts nothing and pins nothing.
2456
+ *
2457
+ * `legacySort` is the order the calling read produced BEFORE this field existed, and is what a cursor with no
2458
+ * recorded key is compared against — such a cursor is not of unknown order, it is of that read's old one.
2389
2459
  */
2390
2460
  async searchIssuesByAlias(provider, token, searches, options, cancellation) {
2391
2461
  const scope = getScopedLogger();
@@ -2394,10 +2464,19 @@ export class GitHubApi {
2394
2464
  // by a cursor string that reads back as page 1, restarting the walk with no error and no truncation flag.
2395
2465
  // Cheap to check, and it fails at the one call that introduced the collision rather than in a consumer's
2396
2466
  // persisted cursor.
2397
- const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated');
2467
+ const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated' || s.alias === 'sort');
2398
2468
  if (reserved.length > 0) {
2399
2469
  throw new Error(`Issue search alias(es) ${reserved.map(s => `'${s.alias}'`).join(', ')} collide with the composite cursor's reserved keys`);
2400
2470
  }
2471
+ // A key GitHub cannot express emits no `sort:` qualifier, so each alias would come back in RELEVANCE order
2472
+ // while the union below is sorted by that key and the cursor sealed under it: an arbitrary subset,
2473
+ // presented as ordered, resumable only into more of the same. Refused here rather than downgraded, which
2474
+ // is the rule the whole feature is built on. Unreachable through the facade — no `supportedIssueSorts`
2475
+ // table declares a key without a qualifier — so this guards the direct callers of the two public reads,
2476
+ // where `title` is expressible enough to have a comparator and not enough to be a GitHub search qualifier.
2477
+ if (options.sort != null && toGitHubIssueSortQualifier(options.sort) == null) {
2478
+ throw new Error(`GitHub cannot order an issue search by '${options.sort}'`);
2479
+ }
2401
2480
  let cursor;
2402
2481
  if (options?.cursor != null) {
2403
2482
  try {
@@ -2405,6 +2484,28 @@ export class GitHubApi {
2405
2484
  }
2406
2485
  catch { }
2407
2486
  }
2487
+ // The order this request is being made under, as the cursor records it.
2488
+ const requestedSort = options.sort ?? unsortedCursorSort;
2489
+ // A cursor produced under a DIFFERENT order can't be resumed: every alias would continue from a position in
2490
+ // a differently-ordered result set, so the continuation re-emits rows already seen and skips rows never
2491
+ // seen. REFUSED rather than silently restarted from page 1, because a restart cannot be reported honestly
2492
+ // from here: this read is cursor-only, so `resolveCurrentPage` has no page of its own to trust and echoes
2493
+ // the `page` the caller supplied alongside the cursor — page 1's rows would be published as page N, which
2494
+ // is the very confusion the fingerprint exists to prevent. Refusing surfaces a warning + `fetchFailed`, and
2495
+ // the remedy ("drop the cursor") is the caller's to apply.
2496
+ //
2497
+ // A cursor with NO recorded sort predates this field — which is not the same as being of unknown order.
2498
+ // Each read produced exactly one order before ordering was an option (`sort:updated` for the filtered
2499
+ // search, relevance for `searchMyIssues`), so an absent key reads as THAT one, `legacySort`. Compared
2500
+ // rather than waved through: the facade now resolves an omitted key to `defaultIssueSort`, so the
2501
+ // account-wide read's query gained a `sort:updated` qualifier it did not have, and resuming a
2502
+ // relevance-ordered cursor inside it advances each alias through a re-ordered result set — the gaps and
2503
+ // repeats this check exists to prevent, arriving through the very case meant to keep working. A cursor
2504
+ // whose implied key matches the request still resumes, and is sealed with the current one.
2505
+ const cursorSort = cursor?.sort ?? options.legacySort;
2506
+ if (cursor != null && cursorSort !== requestedSort) {
2507
+ throw new Error(`Issue search cursor was produced under sort '${cursorSort}' but '${requestedSort}' was requested; restart the read without a cursor`);
2508
+ }
2408
2509
  const page = Math.max(1, Math.trunc(cursor?.page ?? 1));
2409
2510
  // A slot is a continuation string, `null` (exhausted), or absent. Anything else came from a malformed or
2410
2511
  // foreign cursor, and is read as absent rather than threaded back into the request as a continuation.
@@ -2481,11 +2582,32 @@ export class GitHubApi {
2481
2582
  }
2482
2583
  // Dedupe by `url`, not `IssueShape.id`: for some providers `id` is a per-repository number, so an
2483
2584
  // id-keyed map would collapse distinct issues across repositories.
2484
- const results = uniqueBy(issues, r => r.url, (original, _current) => original);
2585
+ const deduped = [
2586
+ ...uniqueBy(issues, r => r.url, (original, _current) => original),
2587
+ ];
2588
+ // Each alias arrived ordered by the server; their concatenation is not, so the merged page is ordered
2589
+ // here. AFTER the dedupe, not before, and that ordering is load-bearing: the alias order is also the
2590
+ // dedupe's precedence (an issue both assigned to and authored by the user surfaces as the assigned one,
2591
+ // per `searchMyIssues`), and sorting first would hand `uniqueBy` a different first occurrence and
2592
+ // silently change which copy wins. The pull-request path sorts BEFORE its dedupe because its facets
2593
+ // carry no such precedence — the difference is deliberate, not an inconsistency to tidy up.
2594
+ //
2595
+ // A comparator is always available for a key GitHub declares (`created`/`updated`/`comments`/
2596
+ // `reactions` are all on `IssueShape`), so `undefined` here means the capability table has outrun this
2597
+ // read; leave the provider's per-alias order rather than inventing one.
2598
+ //
2599
+ // Counted over `active`, not `searches`: continuations exhaust aliases one at a time, so a later page of
2600
+ // a three-category walk can come from ONE surviving search — already ordered by the server. Re-sorting
2601
+ // it could only reproduce that order, while hiding a provider that ignored the qualifier.
2602
+ const comparator = options?.sort != null ? getIssueComparator(options.sort) : undefined;
2603
+ if (comparator != null && active.length > 1) {
2604
+ deduped.sort(comparator);
2605
+ }
2485
2606
  // Every alias gets a slot, so an inactive one keeps its `null` and stays out of the next request. A
2486
2607
  // missing slot would be read as "never requested", which for a `searches` set that still lists it
2487
2608
  // would restart it from its first page.
2488
- const next = { page: page + 1 };
2609
+ // The order is pinned on the way out too, so the next round can refuse a changed key (see above).
2610
+ const next = { page: page + 1, sort: requestedSort };
2489
2611
  let hasMore = false;
2490
2612
  let continuationMissing = false;
2491
2613
  let maxIssueCount = 0;
@@ -2507,7 +2629,7 @@ export class GitHubApi {
2507
2629
  const truncated = cursor?.truncated === true || maxIssueCount > githubSearchResultLimit || continuationMissing;
2508
2630
  next.truncated = truncated || undefined;
2509
2631
  return {
2510
- values: [...results],
2632
+ values: deduped,
2511
2633
  cursor: hasMore ? JSON.stringify(next) : undefined,
2512
2634
  hasMore: hasMore,
2513
2635
  page: page,