@gitkraken/core-gitlens 0.5.108 → 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 (137) hide show
  1. package/CHANGELOG.md +17 -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/integrationService.d.ts +15 -9
  23. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  24. package/dist/plus/integrations/integrationService.js.map +1 -1
  25. package/dist/plus/integrations/manager.d.ts +40 -26
  26. package/dist/plus/integrations/manager.d.ts.map +1 -1
  27. package/dist/plus/integrations/models/gitHostIntegration.d.ts +6 -31
  28. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  29. package/dist/plus/integrations/models/gitHostIntegration.js +11 -1
  30. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  31. package/dist/plus/integrations/models/issueReads.d.ts +63 -1
  32. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -1
  33. package/dist/plus/integrations/models/issuesIntegration.d.ts +7 -21
  34. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  35. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  36. package/dist/plus/integrations/providerFilters.d.ts +1 -1
  37. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  38. package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
  39. package/dist/plus/integrations/providers/azureDevOps.js +1 -0
  40. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  41. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  42. package/dist/plus/integrations/providers/github.js +1 -0
  43. package/dist/plus/integrations/providers/github.js.map +1 -1
  44. package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
  45. package/dist/plus/integrations/providers/gitlab.js +1 -0
  46. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  47. package/dist/plus/integrations/providers/issueSorts.d.ts +36 -0
  48. package/dist/plus/integrations/providers/issueSorts.d.ts.map +1 -0
  49. package/dist/plus/integrations/providers/issueSorts.js +76 -0
  50. package/dist/plus/integrations/providers/issueSorts.js.map +1 -0
  51. package/dist/plus/integrations/providers/jira.d.ts +3 -9
  52. package/dist/plus/integrations/providers/jira.d.ts.map +1 -1
  53. package/dist/plus/integrations/providers/jira.js +5 -1
  54. package/dist/plus/integrations/providers/jira.js.map +1 -1
  55. package/dist/plus/integrations/providers/linear.d.ts +24 -9
  56. package/dist/plus/integrations/providers/linear.d.ts.map +1 -1
  57. package/dist/plus/integrations/providers/linear.js +63 -5
  58. package/dist/plus/integrations/providers/linear.js.map +1 -1
  59. package/dist/plus/integrations/providers/models.d.ts +63 -7
  60. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  61. package/dist/plus/integrations/providers/models.js +55 -0
  62. package/dist/plus/integrations/providers/models.js.map +1 -1
  63. package/dist/plus/integrations/providers/providersApi.d.ts +18 -3
  64. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  65. package/dist/plus/integrations/providers/providersApi.js +24 -7
  66. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  67. package/dist/plus/integrations/providers/trello.d.ts +3 -9
  68. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  69. package/dist/plus/integrations/providers/trello.js +1 -0
  70. package/dist/plus/integrations/providers/trello.js.map +1 -1
  71. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  72. package/dist/plus/integrations/providers/utils/providerPaging.js +5 -1
  73. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  74. package/dist/plus/integrations/reads/broaden.d.ts +6 -0
  75. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  76. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  77. package/dist/plus/integrations/reads/counts.d.ts +8 -1
  78. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  79. package/dist/plus/integrations/reads/counts.js.map +1 -1
  80. package/dist/plus/integrations/reads/filters.d.ts +59 -9
  81. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  82. package/dist/plus/integrations/reads/filters.js +17 -0
  83. package/dist/plus/integrations/reads/filters.js.map +1 -1
  84. package/dist/plus/integrations/reads/issueTracker.d.ts +18 -1
  85. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  86. package/dist/plus/integrations/reads/issueTracker.js +40 -3
  87. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  88. package/dist/plus/integrations/reads/issues.d.ts +27 -3
  89. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  90. package/dist/plus/integrations/reads/issues.js +223 -123
  91. package/dist/plus/integrations/reads/issues.js.map +1 -1
  92. package/dist/plus/integrations/reads/ordering.d.ts +101 -0
  93. package/dist/plus/integrations/reads/ordering.d.ts.map +1 -0
  94. package/dist/plus/integrations/reads/ordering.js +48 -0
  95. package/dist/plus/integrations/reads/ordering.js.map +1 -0
  96. package/dist/plus/integrations/reads/searchIssues.d.ts +7 -2
  97. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
  98. package/dist/plus/integrations/reads/searchIssues.js +9 -3
  99. package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
  100. package/dist/plus/integrations/reads/warnings.d.ts +34 -2
  101. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  102. package/dist/plus/integrations/reads/warnings.js +47 -2
  103. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  104. package/dist/plus/integrations/results.d.ts +10 -0
  105. package/dist/plus/integrations/results.d.ts.map +1 -1
  106. package/dist/plus/integrations/results.js.map +1 -1
  107. package/package.json +2 -2
  108. package/src/git/models/issue.ts +73 -0
  109. package/src/git/utils/issue.utils.ts +91 -1
  110. package/src/plus/git-github/api/github.ts +180 -20
  111. package/src/plus/git-github/api/issueSearchQuery.ts +67 -7
  112. package/src/plus/integrations/collectionMetadata.ts +24 -0
  113. package/src/plus/integrations/integrationService.ts +15 -10
  114. package/src/plus/integrations/manager.ts +40 -31
  115. package/src/plus/integrations/models/gitHostIntegration.ts +18 -31
  116. package/src/plus/integrations/models/issueReads.ts +65 -1
  117. package/src/plus/integrations/models/issuesIntegration.ts +7 -6
  118. package/src/plus/integrations/providerFilters.ts +2 -0
  119. package/src/plus/integrations/providers/azureDevOps.ts +1 -0
  120. package/src/plus/integrations/providers/github.ts +1 -0
  121. package/src/plus/integrations/providers/gitlab.ts +1 -0
  122. package/src/plus/integrations/providers/issueSorts.ts +89 -0
  123. package/src/plus/integrations/providers/jira.ts +9 -3
  124. package/src/plus/integrations/providers/linear.ts +77 -8
  125. package/src/plus/integrations/providers/models.ts +122 -5
  126. package/src/plus/integrations/providers/providersApi.ts +51 -13
  127. package/src/plus/integrations/providers/trello.ts +4 -3
  128. package/src/plus/integrations/providers/utils/providerPaging.ts +6 -1
  129. package/src/plus/integrations/reads/broaden.ts +6 -0
  130. package/src/plus/integrations/reads/counts.ts +8 -1
  131. package/src/plus/integrations/reads/filters.ts +70 -8
  132. package/src/plus/integrations/reads/issueTracker.ts +78 -4
  133. package/src/plus/integrations/reads/issues.ts +386 -194
  134. package/src/plus/integrations/reads/ordering.ts +124 -0
  135. package/src/plus/integrations/reads/searchIssues.ts +15 -3
  136. package/src/plus/integrations/reads/warnings.ts +72 -1
  137. package/src/plus/integrations/results.ts +10 -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,
@@ -27,6 +28,7 @@ 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';
30
32
  import {
31
33
  createRevisionRange,
32
34
  getRevisionRangeParts,
@@ -77,11 +79,21 @@ import {
77
79
  gitHubIssueSearchRelationships,
78
80
  toGitHubIssueSearchQualifiers,
79
81
  toGitHubIssueSearchScopeQualifiers,
82
+ toGitHubIssueSortQualifier,
80
83
  } from './issueSearchQuery.js';
81
84
  import { toGitHubPullRequestSearchFacets } from './pullRequestSearchQuery.js';
82
85
  import type { GitHubTokenInfo } from './token.js';
83
86
 
84
87
  const emptyPagedResult: PagedResult<any> = Object.freeze({ values: [] });
88
+ /**
89
+ * What an issue-search cursor records when the caller asked for no ordering at all.
90
+ *
91
+ * A sentinel rather than an omitted field, because omitted already means something else and more important: a
92
+ * cursor persisted before ordering existed. Distinguishing the two is what lets an old cursor keep resuming while
93
+ * a genuine change from unordered to ordered is still refused. Not an `IssueSorting`, so it can never collide
94
+ * with one.
95
+ */
96
+ const unsortedCursorSort = 'unsorted' as const;
85
97
  const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] });
86
98
 
87
99
  // Transient gateway/network failures (e.g. an upstream `502 Bad Gateway`) are worth a few quick
@@ -3672,6 +3684,16 @@ export class GitHubApi {
3672
3684
  return (await this.searchMyPullRequestsPage(provider, token, options, cancellation)).values;
3673
3685
  }
3674
3686
 
3687
+ /**
3688
+ * The current user's issues: authored ∪ assigned ∪ mentioned, each its own aliased search behind one composite
3689
+ * cursor. Bound to `@me` by construction, unlike {@link searchIssuesPage}.
3690
+ *
3691
+ * Ordering is OPT-IN here, and that asymmetry with {@link searchIssuesPage} is deliberate: this read has never
3692
+ * requested a sort, so GitHub has always answered it in relevance order. Emitting a default would change which
3693
+ * issues its already-shipped consumers see, so an omitted `sort` still emits no `sort:` qualifier at all and
3694
+ * keeps today's result. Pass one to get a defined order — which is also what makes a page budget meaningful,
3695
+ * since relevance ranking can shift under an unchanged upstream.
3696
+ */
3675
3697
  @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3676
3698
  async searchMyIssues(
3677
3699
  provider: Provider,
@@ -3685,6 +3707,8 @@ export class GitHubApi {
3685
3707
  includeBody?: boolean;
3686
3708
  includeAllAssignees?: boolean;
3687
3709
  cursor?: string;
3710
+ /** Requested order. Omitted leaves GitHub's relevance order, which is what this read has always served. */
3711
+ sort?: IssueSorting;
3688
3712
  /**
3689
3713
  * Which of the three "my issues" searches to run. Omitted runs all three (GitHub's own definition of
3690
3714
  * "mine": authored ∪ assigned ∪ mentioned). Supplied, only the `true` ones run — so a caller wanting
@@ -3710,7 +3734,10 @@ export class GitHubApi {
3710
3734
  search += `${repo}${options.repos.join(repo)}`;
3711
3735
  }
3712
3736
 
3713
- const baseFilters = 'type:issue is:open archived:false';
3737
+ // A requested sort goes through the same table `searchIssuesPage` uses, so the two GitHub issue reads can't
3738
+ // diverge the first time a key is added. Omitted appends nothing — see this method's contract above.
3739
+ const sortQualifier = toGitHubIssueSortQualifier(options?.sort);
3740
+ const baseFilters = ['type:issue is:open archived:false', sortQualifier].filter(Boolean).join(' ');
3714
3741
  // `includeAllAssignees` broadens the assigned category from "assigned to me" to "assigned to anyone"
3715
3742
  // (`assignee:*` is GitHub's has-any-assignee qualifier). Authored/mentioned stay bound to `@me` — they're
3716
3743
  // user-relative by definition, so an all-assignees read still only surfaces the current user's authored
@@ -3748,7 +3775,24 @@ export class GitHubApi {
3748
3775
  searches.push({ alias: 'authored', query: `${search} ${baseFilters} author:@me`.trim() });
3749
3776
  }
3750
3777
 
3751
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
3778
+ // Field by field, like `searchIssuesPage`: `options` also carries `repos`/`includeAllAssignees`/`categories`,
3779
+ // already folded into `searches[].query` above and undeclared by the callee.
3780
+ return this.searchIssuesByAlias(
3781
+ provider,
3782
+ token,
3783
+ searches,
3784
+ {
3785
+ baseUrl: options?.baseUrl,
3786
+ avatarSize: options?.avatarSize,
3787
+ includeBody: options?.includeBody,
3788
+ cursor: options?.cursor,
3789
+ sort: options?.sort,
3790
+ // This read emitted no `sort:` qualifier at all before ordering existed, so a cursor with no
3791
+ // recorded key came out of a relevance-ordered walk.
3792
+ legacySort: unsortedCursorSort,
3793
+ },
3794
+ cancellation,
3795
+ );
3752
3796
  }
3753
3797
 
3754
3798
  /**
@@ -3756,9 +3800,15 @@ export class GitHubApi {
3756
3800
  * relationship to the current user. The issue counterpart of {@link searchMyPullRequestsPage}, and distinct
3757
3801
  * from {@link searchMyIssues}, which is permanently bound to `@me`.
3758
3802
  *
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.
3803
+ * Ordering is `criteria.sort`, defaulting to most-recently-updated-first the order this read served before
3804
+ * ordering was an option, so an omitted `sort` emits the identical query. What is NOT optional is that SOME
3805
+ * order is always requested: without one GitHub answers in relevance order, and at the result ceiling that
3806
+ * makes which rows are reachable a function of GitHub's ranking rather than of the request. A key GitHub can't
3807
+ * express (`closed`, `priority`, …) is refused by the facade before the request, not silently downgraded.
3808
+ *
3809
+ * With more than one relationship the page is a UNION of several searches, each ordered by the provider; the
3810
+ * merged page is re-sorted here so the whole page honors the requested key. Across pages the order is still
3811
+ * per-alias — see {@link searchIssuesByAlias}.
3762
3812
  *
3763
3813
  * Each requested relationship becomes its own aliased search, unioned and deduped by url; with none, a single
3764
3814
  * search runs over the scope alone. `criteria.text` and the other free-form values are sanitized so user input
@@ -3780,9 +3830,12 @@ export class GitHubApi {
3780
3830
  },
3781
3831
  cancellation?: AbortSignal,
3782
3832
  ): Promise<AliasedIssueSearchResult | undefined> {
3833
+ // Resolved once: the emitted qualifier, the merged page's comparator and the cursor's fingerprint must all
3834
+ // be the same key, which is what `effectiveIssueSort` exists to guarantee.
3835
+ const sort = effectiveIssueSort(options?.criteria?.sort);
3783
3836
  const base = [
3784
3837
  ...toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos),
3785
- ...toGitHubIssueSearchQualifiers(options?.criteria),
3838
+ ...toGitHubIssueSearchQualifiers(options?.criteria, sort),
3786
3839
  ].join(' ');
3787
3840
 
3788
3841
  // One aliased search per relationship, OR-ed by union. They can't be one query: GitHub AND-s qualifiers,
@@ -3796,7 +3849,27 @@ export class GitHubApi {
3796
3849
  }))
3797
3850
  : [{ alias: 'matched', query: base }];
3798
3851
 
3799
- return this.searchIssuesByAlias(provider, token, searches, options, cancellation);
3852
+ // Forwarded field by field rather than spread: `options` also carries `repos`/`org`/`criteria`, which are
3853
+ // already baked into `searches[].query` above and which the callee declares nothing about. `sort` is the
3854
+ // EFFECTIVE key, since the merged page and the cursor's fingerprint must both use the one the query used.
3855
+ return this.searchIssuesByAlias(
3856
+ provider,
3857
+ token,
3858
+ searches,
3859
+ {
3860
+ baseUrl: options?.baseUrl,
3861
+ avatarSize: options?.avatarSize,
3862
+ includeBody: options?.includeBody,
3863
+ cursor: options?.cursor,
3864
+ pageSize: options?.pageSize,
3865
+ sort: sort,
3866
+ // This read has always emitted `sort:updated`, which is `defaultIssueSort` — so a cursor with no
3867
+ // recorded key came out of a walk under exactly that key, and only a caller asking for a
3868
+ // different one has to restart.
3869
+ legacySort: defaultIssueSort,
3870
+ },
3871
+ cancellation,
3872
+ );
3800
3873
  }
3801
3874
 
3802
3875
  /**
@@ -3823,9 +3896,12 @@ export class GitHubApi {
3823
3896
  if (scopes.length === 0) return [];
3824
3897
 
3825
3898
  const queries = scopes.map(s => {
3899
+ // The same resolved key the search would use, so the count previews the query it previews. Ordering
3900
+ // cannot change a total, but emitting a DIFFERENT qualifier string than the search does would break the
3901
+ // parity this probe is for.
3826
3902
  const qualifiers = [
3827
3903
  ...toGitHubIssueSearchScopeQualifiers(s.org, s.repos),
3828
- ...toGitHubIssueSearchQualifiers(s.criteria),
3904
+ ...toGitHubIssueSearchQualifiers(s.criteria, effectiveIssueSort(s.criteria?.sort)),
3829
3905
  ];
3830
3906
 
3831
3907
  // A relationship set is OR-ed across searches, which a single count can't express — the facade splits
@@ -3886,14 +3962,31 @@ export class GitHubApi {
3886
3962
  * {@link searchMyIssues} is one configuration of it (its three `@me` categories), and its alias names are
3887
3963
  * that read's published cursor keys.
3888
3964
  *
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.
3965
+ * `searches` must have unique aliases, each a valid GraphQL name that is none of `page`, `truncated` or
3966
+ * `sort` — the composite cursor keys aliases at its top level, alongside those three reserved fields.
3967
+ *
3968
+ * `sort` is the order the caller asked for, which this does two things with. Each alias comes back ordered by
3969
+ * it (the qualifier is already in `searches[].query`), but the UNION of several aliases is not, so the merged
3970
+ * page is re-sorted here; and the key is recorded in the cursor, so a continuation that changed it THROWS
3971
+ * rather than serving a sequence with gaps and repeats. Omitted means the caller asked for no order at all
3972
+ * ({@link searchMyIssues}'s default), which re-sorts nothing and pins nothing.
3973
+ *
3974
+ * `legacySort` is the order the calling read produced BEFORE this field existed, and is what a cursor with no
3975
+ * recorded key is compared against — such a cursor is not of unknown order, it is of that read's old one.
3891
3976
  */
3892
3977
  private async searchIssuesByAlias(
3893
3978
  provider: Provider,
3894
3979
  token: GitHubTokenInfo,
3895
3980
  searches: readonly AliasedIssueSearch[],
3896
- options?: { baseUrl?: string; avatarSize?: number; includeBody?: boolean; cursor?: string; pageSize?: number },
3981
+ options: {
3982
+ baseUrl?: string;
3983
+ avatarSize?: number;
3984
+ includeBody?: boolean;
3985
+ cursor?: string;
3986
+ pageSize?: number;
3987
+ sort?: IssueSorting;
3988
+ legacySort: IssueSorting | typeof unsortedCursorSort;
3989
+ },
3897
3990
  cancellation?: AbortSignal,
3898
3991
  ): Promise<AliasedIssueSearchResult | undefined> {
3899
3992
  const scope = getScopedLogger();
@@ -3914,6 +4007,16 @@ export class GitHubApi {
3914
4007
  interface SearchCursor {
3915
4008
  page?: number;
3916
4009
  truncated?: boolean;
4010
+ /**
4011
+ * The order this cursor's pages were produced under: an `IssueSorting`, or `unsortedCursorSort` when
4012
+ * the caller asked for none. Written as a value rather than left absent in the no-order case
4013
+ * specifically so that ABSENT keeps meaning "cursor from before ordering existed", which is accepted
4014
+ * and sealed instead of refused — a consumer's persisted cursor has to keep working across this change.
4015
+ *
4016
+ * Those three cases are the whole domain, so it is typed as them rather than as `string`: a foreign
4017
+ * cursor carrying something else is caught by the mismatch check either way.
4018
+ */
4019
+ sort?: IssueSorting | typeof unsortedCursorSort;
3917
4020
  [alias: string]: string | number | boolean | null | undefined;
3918
4021
  }
3919
4022
 
@@ -3922,19 +4025,54 @@ export class GitHubApi {
3922
4025
  // by a cursor string that reads back as page 1, restarting the walk with no error and no truncation flag.
3923
4026
  // Cheap to check, and it fails at the one call that introduced the collision rather than in a consumer's
3924
4027
  // persisted cursor.
3925
- const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated');
4028
+ const reserved = searches.filter(s => s.alias === 'page' || s.alias === 'truncated' || s.alias === 'sort');
3926
4029
  if (reserved.length > 0) {
3927
4030
  throw new Error(
3928
4031
  `Issue search alias(es) ${reserved.map(s => `'${s.alias}'`).join(', ')} collide with the composite cursor's reserved keys`,
3929
4032
  );
3930
4033
  }
3931
4034
 
4035
+ // A key GitHub cannot express emits no `sort:` qualifier, so each alias would come back in RELEVANCE order
4036
+ // while the union below is sorted by that key and the cursor sealed under it: an arbitrary subset,
4037
+ // presented as ordered, resumable only into more of the same. Refused here rather than downgraded, which
4038
+ // is the rule the whole feature is built on. Unreachable through the facade — no `supportedIssueSorts`
4039
+ // table declares a key without a qualifier — so this guards the direct callers of the two public reads,
4040
+ // where `title` is expressible enough to have a comparator and not enough to be a GitHub search qualifier.
4041
+ if (options.sort != null && toGitHubIssueSortQualifier(options.sort) == null) {
4042
+ throw new Error(`GitHub cannot order an issue search by '${options.sort}'`);
4043
+ }
4044
+
3932
4045
  let cursor: SearchCursor | undefined;
3933
4046
  if (options?.cursor != null) {
3934
4047
  try {
3935
4048
  cursor = JSON.parse(options.cursor) as SearchCursor;
3936
4049
  } catch {}
3937
4050
  }
4051
+ // The order this request is being made under, as the cursor records it.
4052
+ const requestedSort = options.sort ?? unsortedCursorSort;
4053
+ // A cursor produced under a DIFFERENT order can't be resumed: every alias would continue from a position in
4054
+ // a differently-ordered result set, so the continuation re-emits rows already seen and skips rows never
4055
+ // seen. REFUSED rather than silently restarted from page 1, because a restart cannot be reported honestly
4056
+ // from here: this read is cursor-only, so `resolveCurrentPage` has no page of its own to trust and echoes
4057
+ // the `page` the caller supplied alongside the cursor — page 1's rows would be published as page N, which
4058
+ // is the very confusion the fingerprint exists to prevent. Refusing surfaces a warning + `fetchFailed`, and
4059
+ // the remedy ("drop the cursor") is the caller's to apply.
4060
+ //
4061
+ // A cursor with NO recorded sort predates this field — which is not the same as being of unknown order.
4062
+ // Each read produced exactly one order before ordering was an option (`sort:updated` for the filtered
4063
+ // search, relevance for `searchMyIssues`), so an absent key reads as THAT one, `legacySort`. Compared
4064
+ // rather than waved through: the facade now resolves an omitted key to `defaultIssueSort`, so the
4065
+ // account-wide read's query gained a `sort:updated` qualifier it did not have, and resuming a
4066
+ // relevance-ordered cursor inside it advances each alias through a re-ordered result set — the gaps and
4067
+ // repeats this check exists to prevent, arriving through the very case meant to keep working. A cursor
4068
+ // whose implied key matches the request still resumes, and is sealed with the current one.
4069
+ const cursorSort = cursor?.sort ?? options.legacySort;
4070
+ if (cursor != null && cursorSort !== requestedSort) {
4071
+ throw new Error(
4072
+ `Issue search cursor was produced under sort '${cursorSort}' but '${requestedSort}' was requested; restart the read without a cursor`,
4073
+ );
4074
+ }
4075
+
3938
4076
  const page = Math.max(1, Math.trunc(cursor?.page ?? 1));
3939
4077
  // A slot is a continuation string, `null` (exhausted), or absent. Anything else came from a malformed or
3940
4078
  // foreign cursor, and is read as absent rather than threaded back into the request as a continuation.
@@ -4023,16 +4161,38 @@ export class GitHubApi {
4023
4161
 
4024
4162
  // Dedupe by `url`, not `IssueShape.id`: for some providers `id` is a per-repository number, so an
4025
4163
  // 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
- );
4164
+ const deduped = [
4165
+ ...uniqueBy(
4166
+ issues,
4167
+ r => r.url,
4168
+ (original, _current) => original,
4169
+ ),
4170
+ ];
4171
+
4172
+ // Each alias arrived ordered by the server; their concatenation is not, so the merged page is ordered
4173
+ // here. AFTER the dedupe, not before, and that ordering is load-bearing: the alias order is also the
4174
+ // dedupe's precedence (an issue both assigned to and authored by the user surfaces as the assigned one,
4175
+ // per `searchMyIssues`), and sorting first would hand `uniqueBy` a different first occurrence and
4176
+ // silently change which copy wins. The pull-request path sorts BEFORE its dedupe because its facets
4177
+ // carry no such precedence — the difference is deliberate, not an inconsistency to tidy up.
4178
+ //
4179
+ // A comparator is always available for a key GitHub declares (`created`/`updated`/`comments`/
4180
+ // `reactions` are all on `IssueShape`), so `undefined` here means the capability table has outrun this
4181
+ // read; leave the provider's per-alias order rather than inventing one.
4182
+ //
4183
+ // Counted over `active`, not `searches`: continuations exhaust aliases one at a time, so a later page of
4184
+ // a three-category walk can come from ONE surviving search — already ordered by the server. Re-sorting
4185
+ // it could only reproduce that order, while hiding a provider that ignored the qualifier.
4186
+ const comparator = options?.sort != null ? getIssueComparator(options.sort) : undefined;
4187
+ if (comparator != null && active.length > 1) {
4188
+ deduped.sort(comparator);
4189
+ }
4031
4190
 
4032
4191
  // Every alias gets a slot, so an inactive one keeps its `null` and stays out of the next request. A
4033
4192
  // missing slot would be read as "never requested", which for a `searches` set that still lists it
4034
4193
  // would restart it from its first page.
4035
- const next: SearchCursor = { page: page + 1 };
4194
+ // The order is pinned on the way out too, so the next round can refuse a changed key (see above).
4195
+ const next: SearchCursor = { page: page + 1, sort: requestedSort };
4036
4196
  let hasMore = false;
4037
4197
  let continuationMissing = false;
4038
4198
  let maxIssueCount = 0;
@@ -4057,7 +4217,7 @@ export class GitHubApi {
4057
4217
  cursor?.truncated === true || maxIssueCount > githubSearchResultLimit || continuationMissing;
4058
4218
  next.truncated = truncated || undefined;
4059
4219
  return {
4060
- values: [...results],
4220
+ values: deduped,
4061
4221
  cursor: hasMore ? JSON.stringify(next) : undefined,
4062
4222
  hasMore: hasMore,
4063
4223
  page: page,
@@ -1,4 +1,4 @@
1
- import type { IssueSearchCriteria, IssueSearchRelationship } from '../../../git/models/issue.js';
1
+ import type { IssueSearchCriteria, IssueSearchRelationship, IssueSorting } from '../../../git/models/issue.js';
2
2
 
3
3
  /**
4
4
  * Translation of the provider-neutral issue-search criteria into GitHub search qualifiers, and the sanitizing
@@ -70,6 +70,56 @@ export const gitHubIssueSearchRelationships: Record<IssueSearchRelationship, { q
70
70
  unassigned: { qualifier: 'no:assignee', alias: 'unassigned' },
71
71
  };
72
72
 
73
+ /**
74
+ * How each orderable key becomes a GitHub `sort:` qualifier.
75
+ *
76
+ * A literal table rather than a derived transform, for the same reason {@link gitHubIssueSearchRelationships} is
77
+ * one: being a `Record` over the union means adding a sort field without deciding its qualifier FAILS THE BUILD,
78
+ * instead of quietly emitting a search with no ordering constraint at all — which is the one failure mode the
79
+ * ordering contract exists to prevent, and the one a caller cannot detect from the result.
80
+ *
81
+ * `updated:desc` maps to the bare `sort:updated`, not `sort:updated-desc`. The two are the same query to GitHub,
82
+ * but the bare form is what this read has always emitted, so keeping it makes the default byte-identical to
83
+ * today's query — and the exact-query tests and recorded fixtures stay valid. Do not "normalize" it.
84
+ *
85
+ * `reactions` maps to the per-emoji `sort:reactions-+1`, NOT the bare `sort:reactions`. GitHub's bare form orders
86
+ * by the total across every reaction type, but the only reaction count on an `IssueShape` is `thumbsUpCount`,
87
+ * which this client fills from `reactions(content: THUMBS_UP)`. Ordering by the total would rank the page by a
88
+ * number the caller cannot see — and would disagree with `getIssueComparator`, which re-sorts a merged page from
89
+ * `thumbsUpCount`, so the same request would order one way with a single relationship and another way with two.
90
+ * `@gitkraken/provider-apis` maps it the same way for the same reason.
91
+ *
92
+ * Absent keys are absent on purpose: `closed` is NOT here even though `IssueShape.closedDate` exists, because
93
+ * GitHub's search cannot order by close date and a client-side sort over a page cut off at the result ceiling is
94
+ * exactly the lie the contract avoids. `sorts` in `githubIssueSearchCapabilities` is the same set, and a parity
95
+ * test pins the two together.
96
+ */
97
+ export const gitHubIssueSortQualifiers: Partial<Record<IssueSorting, string>> = {
98
+ 'created:asc': 'sort:created-asc',
99
+ 'created:desc': 'sort:created-desc',
100
+ 'updated:asc': 'sort:updated-asc',
101
+ 'updated:desc': 'sort:updated',
102
+ 'comments:asc': 'sort:comments-asc',
103
+ 'comments:desc': 'sort:comments-desc',
104
+ 'reactions:asc': 'sort:reactions-+1-asc',
105
+ 'reactions:desc': 'sort:reactions-+1-desc',
106
+ };
107
+
108
+ /**
109
+ * The `sort:` qualifier for a key, or `undefined` when there is no key or GitHub can't express it.
110
+ *
111
+ * Applies no default of its own: the two callers differ on whether they HAVE one — the filtered search always
112
+ * orders, and `searchMyIssues` has never requested an order at all — so the default belongs at the call site that
113
+ * has one rather than behind a guard at the one that doesn't.
114
+ *
115
+ * `undefined` for an inexpressible key rather than a fallback to something orderable: every caller validated
116
+ * against the declared capability first, so reaching that case means the capability table has outrun this one, and
117
+ * a silently unordered search would change which rows land inside the result ceiling.
118
+ */
119
+ export function toGitHubIssueSortQualifier(sort: IssueSorting | undefined): string | undefined {
120
+ return sort != null ? gitHubIssueSortQualifiers[sort] : undefined;
121
+ }
122
+
73
123
  /**
74
124
  * The scope half of an issue search query: `org:` plus one `repo:` per repository.
75
125
  *
@@ -106,11 +156,18 @@ export function toGitHubIssueSearchScopeQualifiers(
106
156
  * becomes its own aliased search, since GitHub AND-s qualifiers and relationships are OR-ed) and excluding the
107
157
  * repository/org scope (which {@link toGitHubIssueSearchScopeQualifiers} owns).
108
158
  *
109
- * `sort:updated` is always emitted: the read's contract is most-recently-updated-first, so a consumer's "show the
110
- * N most recent" policy at the result ceiling is correct. Free-form values go through the sanitizers above, and
111
- * one emptied by sanitizing is dropped rather than emitted as an empty qualifier.
159
+ * A `sort:` qualifier is ALWAYS emitted. Never none: the provider serves at most a bounded window of matches, and
160
+ * without an explicit order GitHub answers in relevance order, so which rows land inside that window would shift
161
+ * with GitHub's ranking even when nothing changed upstream. A key GitHub can't express is refused before this by
162
+ * the facade, so it cannot arrive here.
163
+ *
164
+ * `sort` is passed in rather than read off `criteria` so that the qualifier this emits and the key the caller
165
+ * records elsewhere cannot disagree — see `effectiveIssueSort`.
166
+ *
167
+ * Free-form values go through the sanitizers above, and one emptied by sanitizing is dropped rather than emitted
168
+ * as an empty qualifier.
112
169
  */
113
- export function toGitHubIssueSearchQualifiers(criteria: IssueSearchCriteria | undefined): string[] {
170
+ export function toGitHubIssueSearchQualifiers(criteria: IssueSearchCriteria | undefined, sort: IssueSorting): string[] {
114
171
  const qualifiers = ['type:issue'];
115
172
 
116
173
  switch (criteria?.state) {
@@ -158,8 +215,11 @@ export function toGitHubIssueSearchQualifiers(criteria: IssueSearchCriteria | un
158
215
  }
159
216
  }
160
217
 
161
- // Contract, not an option: see `GitHubApi.searchIssuesPage`.
162
- qualifiers.push('sort:updated');
218
+ // Last, where it has always been: the exact-query tests pin the whole qualifier string, not a set.
219
+ const sortQualifier = toGitHubIssueSortQualifier(sort);
220
+ if (sortQualifier != null) {
221
+ qualifiers.push(sortQualifier);
222
+ }
163
223
 
164
224
  return qualifiers;
165
225
  }
@@ -4,12 +4,36 @@ import type {
4
4
  CollectionScope,
5
5
  CollectionScopeFailure,
6
6
  } from '@gitkraken/provider-apis';
7
+ // Matched by its `code` discriminator rather than by `instanceof`: the SDK ships one bundle per entry point, so
8
+ // the class reached through the root is not the same object as the one reached through `/providers`.
9
+ import { isInvalidRequestError, isUnsupportedSortError } from '@gitkraken/provider-apis';
7
10
  import { AuthenticationError, RequestNotFoundError, RequestRateLimitError } from '../../git/errors.js';
8
11
  import type { IntegrationIds } from './constants.js';
9
12
  import { isRateLimitResponse } from './errors.js';
10
13
  import type { ProviderWarning, ProviderWarningOmission } from './results.js';
11
14
  import { appendDedupedWarning } from './results.js';
12
15
 
16
+ /**
17
+ * Re-throws an error that is a fact about the CALL rather than about one scope of a fan-out.
18
+ *
19
+ * Every provider fan-out catches per-scope rejections and records them as {@link CollectionScopeFailure}s, which is
20
+ * right for auth, rate limits and a missing project: those really did happen to one scope, and the siblings that
21
+ * succeeded must survive. A caller-contract error is the opposite — it is identical for every scope and was decided
22
+ * before any request went out — so degrading it would hand back N indistinguishable failures and an empty `partial`
23
+ * page, describing an invalid call as an incomplete read.
24
+ *
25
+ * Today that means the SDK's `UnsupportedSortError` and `InvalidRequestError` — the two codes it lists in its own
26
+ * `CALLER_CONTRACT_ERROR_CODES`, both documented as "never degraded into a `CollectionScopeFailure` or an
27
+ * omission". The second covers arguments rejected before the first request goes out: a project key list longer
28
+ * than one Jira search can carry (or an empty one), a non-integer `pageSize`, an unknown current-user filter, or a
29
+ * Jira Server read with no `baseUrl` to address. Called from every fan-out rather than spelled out in each, so a
30
+ * fan-out added later inherits the rule instead of silently omitting it — which is exactly how Azure's
31
+ * account-wide drain came to degrade it while its three siblings did not.
32
+ */
33
+ export function throwIfCallerContractError(ex: unknown): void {
34
+ if (isUnsupportedSortError(ex) || isInvalidRequestError(ex)) throw ex;
35
+ }
36
+
13
37
  /**
14
38
  * Maps a caught GitLens request error to the SDK collection failure vocabulary used inside provider fan-outs.
15
39
  */
@@ -67,12 +67,11 @@ import { createApiClients } from './providers/apiClients.js';
67
67
  import type { GitHubApi } from './providers/github/github.js';
68
68
  import type {
69
69
  IssueFilter,
70
- IssueSearchCapabilities,
70
+ IssueSorting,
71
71
  ProviderOrganization,
72
72
  ProviderReposInput,
73
73
  ProviderRepositoryShape,
74
74
  PullRequestFilter,
75
- PullRequestSearchCapabilities,
76
75
  } from './providers/models.js';
77
76
  import { providersMetadata } from './providers/models.js';
78
77
  import type { ProvidersApi } from './providers/providersApi.js';
@@ -80,6 +79,7 @@ import { broadenIssues } from './reads/broaden.js';
80
79
  import type { RepositoryResolutionContext } from './reads/context.js';
81
80
  import type { IssueCountResult, IssueCountScope } from './reads/counts.js';
82
81
  import { countIssues } from './reads/counts.js';
82
+ import type { SupportedFilters } from './reads/filters.js';
83
83
  import { getSupportedFilters } from './reads/filters.js';
84
84
  import { listOrgs, listProjects, listRepos } from './reads/hierarchy.js';
85
85
  import { listIssuesPage } from './reads/issues.js';
@@ -537,14 +537,7 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
537
537
  * stays a method here even though it needs no instance state; see {@link getSupportedFilters} for the
538
538
  * capability table itself and why a consumer should intersect against it before reading.
539
539
  */
540
- getSupportedFilters(providerId: IntegrationIds): {
541
- pullRequests: PullRequestFilter[];
542
- pullRequestsAccountWide: PullRequestFilter[];
543
- pullRequestSearch: PullRequestSearchCapabilities;
544
- issues: IssueFilter[];
545
- issuesAccountWide: IssueFilter[];
546
- issueSearch: IssueSearchCapabilities;
547
- } {
540
+ getSupportedFilters(providerId: IntegrationIds): SupportedFilters {
548
541
  return getSupportedFilters(providerId);
549
542
  }
550
543
 
@@ -1092,6 +1085,13 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1092
1085
  filters?: IssueFilter[];
1093
1086
  /** Broadens the read to every assignee. Contradicts `filters`; passing both is refused. */
1094
1087
  includeAllAssignees?: boolean;
1088
+ /**
1089
+ * How to order the page, as `field:direction`. Omitted orders most-recently-updated-first wherever the
1090
+ * provider can express it. Validated against `getSupportedFilters().issueSorts` on the repo-scoped path and
1091
+ * `.issueSortsAccountWide` on the account-wide one, and refused rather than downgraded; a key no normalized
1092
+ * issue carries is additionally refused for a page spanning several repositories/projects, which is a merge.
1093
+ */
1094
+ sort?: IssueSorting;
1095
1095
  page?: number;
1096
1096
  cursor?: string;
1097
1097
  itemsPerPage?: number;
@@ -1174,6 +1174,11 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1174
1174
  project?: string;
1175
1175
  filters?: IssueFilter[];
1176
1176
  includeAllAssignees?: boolean;
1177
+ /**
1178
+ * How to order the issues, as `field:direction`. Validated against `getSupportedFilters().issueSorts`, which
1179
+ * is where a tracker reports; a key no normalized issue carries is refused for a multi-project page.
1180
+ */
1181
+ sort?: IssueSorting;
1177
1182
  forceSync?: boolean;
1178
1183
  page?: number;
1179
1184
  cursor?: string;
@@ -8,13 +8,9 @@ import type { Event } from '../../utils/event.js';
8
8
  import type { ConfiguredIntegrationsChangeEvent } from './authentication/configuredIntegrationService.js';
9
9
  import type { ConfiguredIntegrationDescriptor } from './authentication/models.js';
10
10
  import type { IntegrationIds } from './constants.js';
11
- import type {
12
- IssueFilter,
13
- IssueSearchCapabilities,
14
- PullRequestFilter,
15
- PullRequestSearchCapabilities,
16
- } from './providerFilters.js';
11
+ import type { IssueFilter, IssueSorting, PullRequestFilter } from './providerFilters.js';
17
12
  import type { IssueCountResult, IssueCountScope } from './reads/counts.js';
13
+ import type { SupportedFilters } from './reads/filters.js';
18
14
  import type {
19
15
  ConnectionStateChangeEvent,
20
16
  ProviderBroadenResult,
@@ -281,24 +277,7 @@ export interface IntegrationManager {
281
277
  * This is a capability table, not a recommendation: a consumer matching another tool's behavior may pass fewer
282
278
  * filters than are listed, or none where the underlying read is already scoped.
283
279
  */
284
- getSupportedFilters(providerId: IntegrationIds): {
285
- pullRequests: PullRequestFilter[];
286
- /** Optional for structural compatibility; missing means no account-wide narrowing filters are supported. */
287
- pullRequestsAccountWide?: PullRequestFilter[];
288
- /**
289
- * Criteria and scopes {@link searchPullRequestsPage} can express. Always present; an empty relationship list
290
- * means the provider exposes no filtered pull-request search.
291
- */
292
- pullRequestSearch: PullRequestSearchCapabilities;
293
- issues: IssueFilter[];
294
- issuesAccountWide: IssueFilter[];
295
- /**
296
- * What {@link searchIssuesPage} (and {@link countIssues}, over the same criteria) can express for this
297
- * provider. Always present: a provider with no filtered issue search reports an empty `relationships` and
298
- * all-false flags, which is the signal to hide that surface rather than to hide individual chips.
299
- */
300
- issueSearch: IssueSearchCapabilities;
301
- };
280
+ getSupportedFilters(providerId: IntegrationIds): SupportedFilters;
302
281
  /** Forces an authoritative cloud connection refresh. Rejects if the backend connection list cannot be read. */
303
282
  refreshConnections(): Promise<void>;
304
283
  /** Rejects unless `connectionId` is a configured cloud connection for `id`. */
@@ -427,6 +406,21 @@ export interface IntegrationManager {
427
406
  filters?: IssueFilter[];
428
407
  /** Broadens to every assignee. On account-wide reads it contradicts `filters`, so passing both is refused. */
429
408
  includeAllAssignees?: boolean;
409
+ /**
410
+ * How to order the page, as `field:direction` (e.g. `updated:desc`, `created:asc`). Omitted orders
411
+ * most-recently-updated-first wherever the provider can express it — this facade's default, not the
412
+ * provider's, which differ from each other.
413
+ *
414
+ * Validated against `getSupportedFilters().issueSorts` when `repos` is supplied and
415
+ * `.issueSortsAccountWide` when it isn't, and refused (warning + `fetchFailed`) rather than downgraded: at
416
+ * the provider's reachable window another order is another subset, so a silently different one is
417
+ * indistinguishable from the one asked for. Keep it fixed across a pagination.
418
+ *
419
+ * With SEVERAL repositories/projects the page is a merge of one query each, so only a key a normalized
420
+ * issue carries can be honored — `priority`, `dueDate` and `resolved` are refused there even where the
421
+ * provider supports them on a single scope. The order applies within a page; across pages it is per scope.
422
+ */
423
+ sort?: IssueSorting;
430
424
  /**
431
425
  * Requested 1-based page. Without a `cursor` this may cost O(page) upstream requests on cursor-backed
432
426
  * reads such as repo-scoped GitHub/GHE; aggregate single-page account-wide reads remain O(1).
@@ -454,16 +448,21 @@ export interface IntegrationManager {
454
448
  * - **Scope is mandatory.** Pass `repos`, `org`, or a user relationship (`authored`/`assigned`/`mentioned`).
455
449
  * `any-assignee`/`unassigned` do NOT scope anything — either alone matches every such issue on the host —
456
450
  * so a call carrying only those is refused (warning + `fetchFailed`).
457
- * - **Ordering is always most-recently-updated-first**, not an option. A "show the N most recent" policy at
458
- * the provider's result ceiling is only correct under a guaranteed order.
451
+ * - **Ordering is `criteria.sort`**, most-recently-updated-first when omitted. It is validated like every other
452
+ * criterion (against `getSupportedFilters().issueSearch.sorts`) and refused rather than downgraded, and SOME
453
+ * order is always requested — a "show the N most recent" policy at the provider's result ceiling is only
454
+ * correct under a guaranteed one. Keep it fixed for the life of a pagination: a cursor is bound to the order
455
+ * that produced it, and threading it under a different key is REFUSED (warning + `fetchFailed`) rather than
456
+ * resumed into a differently-ordered set. To change the order, drop the cursor and read from the first page.
459
457
  * - **At the result ceiling the read SUCCEEDS.** It reports an omission carrying `totalCount` (how many
460
- * matched) and `limit` (how many are reachable) with `recovery: 'none'`, so a consumer can say "19.240
461
- * matched, showing the 1.000 most recent" and know not to offer a "load more". It never falls back to a
462
- * per-repository recovery walk.
458
+ * matched), `limit` (how many are reachable) and `sort` (the order that window was selected under) with
459
+ * `recovery: 'none'`, so a consumer can say "19.240 matched, showing the 1.000 most recent" naming the
460
+ * order, since which 1.000 are reachable depends on it — and know not to offer a "load more". It never falls
461
+ * back to a per-repository recovery walk.
463
462
  *
464
463
  * Check `getSupportedFilters().issueSearch` first: a provider with no filtered issue search reports empty
465
- * relationships (and this read refuses), and a criterion it can't express refuses the whole read rather than
466
- * serving a wider result than asked for.
464
+ * relationships (and this read refuses), and a criterion or sort key it can't express refuses the whole read
465
+ * rather than serving a wider or differently-ordered result than asked for.
467
466
  */
468
467
  searchIssuesPage(options: {
469
468
  providerId: IntegrationIds;
@@ -526,6 +525,16 @@ export interface IntegrationManager {
526
525
  project?: string;
527
526
  filters?: IssueFilter[];
528
527
  includeAllAssignees?: boolean;
528
+ /**
529
+ * How to order the issues, as `field:direction`. Omitted orders most-recently-updated-first where the
530
+ * tracker can express it; validated against `getSupportedFilters().issueSorts` and refused rather than
531
+ * downgraded. A page spanning several projects is a merge, so a key no normalized issue carries
532
+ * (`priority`, `dueDate`, `resolved`) is refused there.
533
+ *
534
+ * Safe to change between pages, unlike the git-host reads: this cursor windows PROJECTS and drains each in
535
+ * full, so which projects a round covers doesn't depend on how their issues are ordered.
536
+ */
537
+ sort?: IssueSorting;
529
538
  forceSync?: boolean;
530
539
  page?: number;
531
540
  /**