@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
@@ -0,0 +1,124 @@
1
+ import type { IssueShape, IssueSorting } from '../../../git/models/issue.js';
2
+ import { defaultIssueSort } from '../../../git/models/issue.js';
3
+ import { getIssueComparator } from '../../../git/utils/issue.utils.js';
4
+
5
+ /**
6
+ * How an issue read decides what order to request, and how to order a page it assembled itself.
7
+ *
8
+ * Its own module rather than more of `filters.ts` because it answers a different question. That file validates what
9
+ * a caller asked to NARROW to; this one resolves what a caller asked to ORDER by — and unlike a filter, an order
10
+ * doesn't only have to be expressible by the provider, it also has to survive the read's own merging. Every issue
11
+ * read makes both decisions in the same shape, so they are resolved here once rather than at each read.
12
+ */
13
+
14
+ /**
15
+ * Why {@link resolveIssueSort} refused a key, carrying what the refusal has to say — the same shape
16
+ * `IssueSearchCriteriaRejection` uses for the filtered search, and for the same reason.
17
+ *
18
+ * The payload travels WITH the rejection rather than being re-read at the warning: the check already held both
19
+ * values, so handing them back is what lets a caller word the refusal without re-deriving anything TypeScript
20
+ * can no longer narrow (which is how this grew five non-null assertions), and it makes it impossible for the
21
+ * message to name a supported list the check didn't use.
22
+ */
23
+ export type UnsupportedIssueSortRejection = {
24
+ reason: 'unsupported-sort';
25
+ /** The key the caller asked for. Non-optional, unlike the `sort` a successful resolve returns. */
26
+ requested: IssueSorting;
27
+ /** Every key this surface CAN express, for the message. `undefined` when the read can't be ordered at all. */
28
+ supported: IssueSorting[] | undefined;
29
+ };
30
+
31
+ /**
32
+ * Resolves the order an issue read should request: validates a caller-supplied key against what one provider
33
+ * surface can express server-side, and supplies this facade's default when the caller asked for none.
34
+ *
35
+ * Three outcomes, and each is a decision rather than a convenience:
36
+ * - A key the surface can't express is REJECTED and the read refused whole, never resolved to the nearest
37
+ * supported key. Same reason a dropped filter isn't dropped: the provider serves at most a bounded window of
38
+ * matches, so a list ordered by something else is a DIFFERENT subset than the one asked for, and the cursor that
39
+ * comes with it describes that other subset — indistinguishable, from the result, from the order requested.
40
+ * - An omitted key becomes {@link defaultIssueSort}, because leaving it to the provider is what makes the same
41
+ * read return different things on different hosts (Azure by creation, Jira by nothing at all). The default is
42
+ * this facade's promise; the SDK deliberately has no opinion.
43
+ * - An omitted key on a surface that can't express even the default returns `undefined`, leaving that provider's
44
+ * own order. A default the caller never asked for must not refuse a read that works today.
45
+ *
46
+ * Takes the supported list rather than an `IntegrationIds`, unlike the filter validators, because which list
47
+ * applies is the CALLER's decision — `supportedIssueSorts` for a repo-scoped or tracker read,
48
+ * `supportedAccountWideIssueSorts` for an account-wide one — and that branch already exists at the call site
49
+ * (`repos` present or not). Resolving it in here would duplicate it.
50
+ */
51
+ export function resolveIssueSort(
52
+ supported: IssueSorting[] | undefined,
53
+ sort: IssueSorting | undefined,
54
+ ): { rejection?: UnsupportedIssueSortRejection; sort?: IssueSorting } {
55
+ if (sort == null) {
56
+ return supported?.includes(defaultIssueSort) ? { sort: defaultIssueSort } : {};
57
+ }
58
+ if (!supported?.includes(sort)) {
59
+ return { rejection: { reason: 'unsupported-sort', requested: sort, supported: supported } };
60
+ }
61
+
62
+ return { sort: sort };
63
+ }
64
+
65
+ /**
66
+ * Why a merged page can't honor the key it was given: the read fans out over several provider queries and no
67
+ * normalized issue carries the field to merge on.
68
+ *
69
+ * Named for the same reason {@link UnsupportedIssueSortRejection} is, and kept separate from it because the two
70
+ * refusals are different claims: that one says the PROVIDER can't express the key, this one says the provider
71
+ * can but this READ's shape can't — the same key against the same provider succeeds on a single scope.
72
+ */
73
+ export type UnmergeableIssueSort = {
74
+ /** The key the caller asked for, which a single-scope read of this provider would have honored. */
75
+ requested: IssueSorting;
76
+ };
77
+
78
+ /**
79
+ * What a read does with the order it resolved, once it also knows whether the page it is about to build MERGES
80
+ * several provider queries.
81
+ *
82
+ * Both facts are bound here, at the one point a read knows them, rather than consulted separately at the refusal
83
+ * and again at the sort. That is the difference this type buys: `merged` and the comparator were each tested twice
84
+ * per read, and the two tests are the same decision — whether the requested order survives this page's shape.
85
+ */
86
+ export type IssueOrdering = {
87
+ /** The key to send to the provider, or `undefined` to leave the provider's own order. */
88
+ readonly sort: IssueSorting | undefined;
89
+ /**
90
+ * Set when the requested key cannot be honored: this page merges several queries, and no normalized issue
91
+ * carries the field to merge on. The read refuses rather than serving concatenated per-scope runs, which would
92
+ * look ordered without being so. `undefined` for a single-query page, where the provider did the ordering.
93
+ *
94
+ * Carries the key rather than being a bare flag, for the same reason {@link UnsupportedIssueSortRejection}
95
+ * does: the refusal needs it, and re-reading `sort` at the call site needs an assertion for an invariant this
96
+ * type would otherwise not express.
97
+ */
98
+ readonly unmergeable: UnmergeableIssueSort | undefined;
99
+ /**
100
+ * Orders a merged page. A no-op for a single-query page (the provider already ordered it, and re-sorting could
101
+ * only reproduce that order while hiding a provider that ignored the key) and for a read with no key at all.
102
+ *
103
+ * Returns a new array rather than sorting in place: the input is often the provider's own `values`, and a read
104
+ * that mutates what it was handed is a surprise waiting for the next caller to share that reference.
105
+ */
106
+ order(items: IssueShape[]): IssueShape[];
107
+ };
108
+
109
+ /**
110
+ * Binds a resolved sort key to the shape of the page about to be built.
111
+ *
112
+ * `merged` is the caller's own count of provider queries reduced to the only thing that matters: GitHub sends one
113
+ * search however many repositories are named, GitLab one per repository, Azure and the trackers one per project,
114
+ * and every account-wide read is a union of several. Above one, the union is what gets published, and only a field
115
+ * an {@link IssueShape} carries can order it.
116
+ */
117
+ export function toIssueOrdering(sort: IssueSorting | undefined, merged: boolean): IssueOrdering {
118
+ const comparator = sort != null ? getIssueComparator(sort) : undefined;
119
+ return {
120
+ sort: sort,
121
+ unmergeable: merged && sort != null && comparator == null ? { requested: sort } : undefined,
122
+ order: (items: IssueShape[]) => (merged && comparator != null ? [...items].sort(comparator) : items),
123
+ };
124
+ }
@@ -1,4 +1,5 @@
1
1
  import type { IssueSearchCriteria, IssueShape } from '../../../git/models/issue.js';
2
+ import { effectiveIssueSort } from '../../../git/utils/issue.utils.js';
2
3
  import type { IntegrationIds } from '../constants.js';
3
4
  import type { ProviderReposInput } from '../providers/models.js';
4
5
  import type { ProviderPagedResult, ProviderWarning } from '../results.js';
@@ -40,8 +41,13 @@ import {
40
41
  * for the same reason `broaden.ts`, `sweeps.ts` and `issueTracker.ts` are separate files.
41
42
  *
42
43
  * Contract details worth knowing before calling:
43
- * - Ordering is ALWAYS most-recently-updated-first. Not an option: a "show the N most recent" policy at the
44
- * provider's result ceiling is only correct under a guaranteed order.
44
+ * - Ordering is `criteria.sort`, most-recently-updated-first by default, and validated like every other criterion
45
+ * against `getSupportedFilters().issueSearch.sorts`. SOME order is always requested a "show the N most recent"
46
+ * policy at the provider's result ceiling is only correct under a guaranteed one, and the ceiling warning names
47
+ * the key it selected the window under. A key the provider can't express refuses the read rather than serving a
48
+ * differently-ordered result; do not change it mid-pagination (a cursor is bound to the order it was made under,
49
+ * and threading it under a different key is refused with a warning + `fetchFailed` — drop the cursor and read
50
+ * from the first page instead).
45
51
  * - Paging is cursor-only. `page` alone walks 1..N internally (O(N) requests); a page past the last one is an
46
52
  * empty page N, never page N−1 relabeled.
47
53
  * - At the provider's result ceiling the read SUCCEEDS and reports an omission carrying the total match count,
@@ -214,7 +220,13 @@ export async function searchIssuesPage(
214
220
  // warning carrying the total. Anything else truncated (an unusable continuation, a provider that stopped
215
221
  // advancing) falls back to the generic wording.
216
222
  warnings.push(
217
- issueSearchCapResultWarning(options.providerId, domain, options.connectionId, totalCount) ??
223
+ issueSearchCapResultWarning(
224
+ options.providerId,
225
+ domain,
226
+ options.connectionId,
227
+ totalCount,
228
+ effectiveIssueSort(options.criteria?.sort),
229
+ ) ??
218
230
  truncationWarning(
219
231
  options.providerId,
220
232
  domain,
@@ -2,7 +2,12 @@ import type { PullRequestShape } from '../../../git/models/pullRequest.js';
2
2
  import { mapBounded } from '../../../utils/promise.js';
3
3
  import type { IntegrationIds } from '../constants.js';
4
4
  import { providerFanOutConcurrency } from '../constants.js';
5
- import type { ClosedPullRequestSweepOptions, PullRequestSweepOptions } from '../manager.js';
5
+ import type {
6
+ ClosedPullRequestSweepOptions,
7
+ ProviderSweepTarget,
8
+ ProviderSweepTargetEvent,
9
+ PullRequestSweepOptions,
10
+ } from '../manager.js';
6
11
  import { fromProviderPullRequest } from '../providers/models.js';
7
12
  import type { ProviderSweepResult, ProviderWarning } from '../results.js';
8
13
  import { appendDedupedWarning } from '../results.js';
@@ -37,89 +42,158 @@ interface SweepSlice {
37
42
  failedProvider: boolean;
38
43
  }
39
44
 
40
- export async function sweepPullRequests(
45
+ /**
46
+ * Drain ONE sweep target. Extracted from the fan-out so the fan-out callback has a single exit: every
47
+ * per-target observation (see `onTargetSettled`) is then reported in one place instead of at each of this
48
+ * function's several early returns, where a missed branch would silently drop a provider's attribution.
49
+ *
50
+ * `undefined` means the target resolved to no reachable connection and is deliberately not attributed in the
51
+ * aggregate result.
52
+ */
53
+ async function sweepTarget(
41
54
  ctx: ProviderReadContext,
42
- options?: PullRequestSweepOptions,
43
- ): Promise<ProviderSweepResult<PullRequestShape>> {
44
- const { targets, attributeUnavailableProviders } = resolvePullRequestSweepTargets(options);
45
- const maxPages = options?.maxPages ?? 100;
55
+ options: PullRequestSweepOptions | undefined,
56
+ target: ProviderSweepTarget,
57
+ attributeUnavailableProviders: boolean,
58
+ ): Promise<SweepSlice | undefined> {
59
+ const { providerId: id, connectionId, domain: requestedDomain } = target;
46
60
  const repos = options?.repos ?? [];
61
+ const maxPages = options?.maxPages ?? 100;
62
+ /** A target that never reached a drain: the provider itself failed, so its slice is empty and attributed. */
63
+ const rejectedTarget = (warnings: ProviderWarning[]): SweepSlice => ({
64
+ items: [],
65
+ warnings: warnings,
66
+ fetchFailed: true,
67
+ truncated: false,
68
+ providerId: id,
69
+ failedProvider: true,
70
+ });
47
71
 
48
- const results = await mapBounded(targets, providerFanOutConcurrency, async target => {
49
- const { providerId: id, connectionId, domain: requestedDomain } = target;
50
- /** A target that never reached a drain: the provider itself failed, so its slice is empty and attributed. */
51
- const rejectedTarget = (warnings: ProviderWarning[]): SweepSlice => ({
52
- items: [],
53
- warnings: warnings,
54
- fetchFailed: true,
55
- truncated: false,
56
- providerId: id,
57
- failedProvider: true,
58
- });
59
-
60
- if (isIssuesHostIntegrationId(id)) {
61
- return rejectedTarget([
62
- gitHostOnlySurfaceWarning(id, requestedDomain, connectionId, 'pull request sweeps'),
63
- ]);
64
- }
72
+ if (isIssuesHostIntegrationId(id)) {
73
+ return rejectedTarget([gitHostOnlySurfaceWarning(id, requestedDomain, connectionId, 'pull request sweeps')]);
74
+ }
65
75
 
66
- const integration = await ctx.getIntegrationForRead(id, connectionId, requestedDomain);
67
- if (integration == null) {
68
- // A requested connection that can't be resolved is a broken connection — surface it as a
69
- // warning + fetchFailed rather than dropping the provider's slice silently.
70
- const early = ctx.earlyReturnConnectionWarnings(id, connectionId, requestedDomain);
71
- if (early.warnings.length === 0 && !attributeUnavailableProviders) return undefined;
76
+ const integration = await ctx.getIntegrationForRead(id, connectionId, requestedDomain);
77
+ if (integration == null) {
78
+ // A requested connection that can't be resolved is a broken connection — surface it as a
79
+ // warning + fetchFailed rather than dropping the provider's slice silently.
80
+ const early = ctx.earlyReturnConnectionWarnings(id, connectionId, requestedDomain);
81
+ if (early.warnings.length === 0 && !attributeUnavailableProviders) return undefined;
72
82
 
73
- return rejectedTarget(
74
- early.warnings.length !== 0 ? early.warnings : [noConnectionWarning(id, requestedDomain, connectionId)],
75
- );
76
- }
77
- if (!isGitHostIntegration(integration)) {
78
- return rejectedTarget([
79
- gitHostOnlySurfaceWarning(id, requestedDomain, connectionId, 'pull request sweeps'),
80
- ]);
81
- }
83
+ return rejectedTarget(
84
+ early.warnings.length !== 0 ? early.warnings : [noConnectionWarning(id, requestedDomain, connectionId)],
85
+ );
86
+ }
87
+ if (!isGitHostIntegration(integration)) {
88
+ return rejectedTarget([gitHostOnlySurfaceWarning(id, requestedDomain, connectionId, 'pull request sweeps')]);
89
+ }
82
90
 
83
- await ctx.forceRefreshIfRequested(integration, options?.forceSync, connectionId);
84
-
85
- const domain = ctx.domainForRead(integration, id, connectionId, requestedDomain);
86
- const accountWide = repos.length === 0;
87
- const requestedFilters = target.filters ?? options?.filters;
88
- const resolved = accountWide
89
- ? resolveAccountWidePullRequestFilters(id, requestedFilters)
90
- : resolvePullRequestFilters(id, requestedFilters);
91
- if (resolved.unsupported) {
92
- return rejectedTarget([
93
- accountWide
94
- ? unsupportedAccountWidePullRequestFiltersWarning(id, domain, connectionId, requestedFilters ?? [])
95
- : unsupportedFiltersWarning(id, domain, connectionId),
96
- ]);
97
- }
91
+ await ctx.forceRefreshIfRequested(integration, options?.forceSync, connectionId);
98
92
 
99
- const drain = await drainPullRequests(
100
- integration,
101
- id,
102
- domain,
103
- repos,
104
- options?.states,
105
- resolved.filters,
106
- accountWide ? (options?.includeReviewRequested ?? false) : false,
107
- connectionId,
108
- maxPages,
109
- attributeUnavailableProviders,
110
- );
111
- const currentAccountId = drain.items.some(pr => pr.author != null)
112
- ? await getCurrentAccountId(integration, connectionId)
113
- : undefined;
114
- // Normalize the raw provider-apis PRs to the GitLens-owned shape here, where the per-provider
115
- // `integration` (the mapper's provider reference) is in scope; the aggregation below only sees drains.
116
- return {
117
- ...drain,
118
- items: drain.items.map(pr =>
119
- fromProviderPullRequest(pr, integration, { currentAccountId: currentAccountId }),
120
- ),
121
- providerId: id,
93
+ const domain = ctx.domainForRead(integration, id, connectionId, requestedDomain);
94
+ const accountWide = repos.length === 0;
95
+ const requestedFilters = target.filters ?? options?.filters;
96
+ const resolved = accountWide
97
+ ? resolveAccountWidePullRequestFilters(id, requestedFilters)
98
+ : resolvePullRequestFilters(id, requestedFilters);
99
+ if (resolved.unsupported) {
100
+ return rejectedTarget([
101
+ accountWide
102
+ ? unsupportedAccountWidePullRequestFiltersWarning(id, domain, connectionId, requestedFilters ?? [])
103
+ : unsupportedFiltersWarning(id, domain, connectionId),
104
+ ]);
105
+ }
106
+
107
+ const drain = await drainPullRequests(
108
+ integration,
109
+ id,
110
+ domain,
111
+ repos,
112
+ options?.states,
113
+ resolved.filters,
114
+ accountWide ? (options?.includeReviewRequested ?? false) : false,
115
+ connectionId,
116
+ maxPages,
117
+ attributeUnavailableProviders,
118
+ );
119
+ const currentAccountId = drain.items.some(pr => pr.author != null)
120
+ ? await getCurrentAccountId(integration, connectionId)
121
+ : undefined;
122
+ // Normalize the raw provider-apis PRs to the GitLens-owned shape here, where the per-provider
123
+ // `integration` (the mapper's provider reference) is in scope; the aggregation below only sees drains.
124
+ return {
125
+ ...drain,
126
+ items: drain.items.map(pr => fromProviderPullRequest(pr, integration, { currentAccountId: currentAccountId })),
127
+ providerId: id,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * How a target ended, as the consumer buckets it. `failedProvider` outranks `fetchFailed` because a target
133
+ * whose provider failed produced no slice to be partial about, and `undefined` is the target that resolved to
134
+ * no reachable connection at all.
135
+ */
136
+ function sliceOutcome(slice: SweepSlice | undefined): ProviderSweepTargetEvent['outcome'] {
137
+ if (slice == null) return 'skipped';
138
+ if (slice.failedProvider) return 'failed-provider';
139
+ if (slice.fetchFailed) return 'fetch-failed';
140
+ return 'ok';
141
+ }
142
+
143
+ /**
144
+ * Per-target reporting for a sweep's `onTargetSettled`, opened once per sweep: this stamps the fan-out start,
145
+ * the returned function stamps one target's start, and the function IT returns is the only thing that can
146
+ * report that target. The timestamps therefore never cross a boundary, so there is no pair of interchangeable
147
+ * numbers for a call site to transpose, and the whole reporting concern is one value the fan-out either has or
148
+ * does not. That is also what keeps the option free when it is omitted: with no observer there is nothing to
149
+ * open, so no clock is read and no reporting object exists — do not "simplify" that into an unconditional
150
+ * open, because a host whose perf gate is off (the default) is entitled to pay nothing for it.
151
+ *
152
+ * The try/catch is what makes the observer observation-only: called from the fan-out's success path, a throwing
153
+ * callback would otherwise propagate out of the `mapBounded` task and reject the entire sweep — corrupting the
154
+ * read, not just the metric. Swallowed silently; the consumer owns its own aggregation.
155
+ *
156
+ * The domain is resolved here rather than carried out of {@link sweepTarget}, so every target reports it by the
157
+ * same rule no matter how far it got. `resolveDomainForRead` needs no integration instance, which is what makes
158
+ * that possible: a target rejected by the first guard resolves the same host a fully drained one does.
159
+ */
160
+ function startSweepReporting(ctx: ProviderReadContext, observe: (event: ProviderSweepTargetEvent) => void) {
161
+ const fanOutStartedAt = performance.now();
162
+
163
+ return function beginTarget(target: ProviderSweepTarget) {
164
+ const startedAt = performance.now();
165
+
166
+ return function reportSettled(slice: SweepSlice | undefined) {
167
+ try {
168
+ observe({
169
+ providerId: target.providerId,
170
+ domain: ctx.resolveDomainForRead(target.providerId, target.connectionId, target.domain),
171
+ connectionId: target.connectionId,
172
+ count: slice?.items.length ?? 0,
173
+ durationMs: performance.now() - startedAt,
174
+ queueWaitMs: startedAt - fanOutStartedAt,
175
+ outcome: sliceOutcome(slice),
176
+ truncated: slice?.truncated ?? false,
177
+ });
178
+ } catch {}
122
179
  };
180
+ };
181
+ }
182
+
183
+ export async function sweepPullRequests(
184
+ ctx: ProviderReadContext,
185
+ options?: PullRequestSweepOptions,
186
+ ): Promise<ProviderSweepResult<PullRequestShape>> {
187
+ const { targets, attributeUnavailableProviders } = resolvePullRequestSweepTargets(options);
188
+
189
+ const observe = options?.onTargetSettled;
190
+ const beginTarget = observe != null ? startSweepReporting(ctx, observe) : undefined;
191
+
192
+ const results = await mapBounded(targets, providerFanOutConcurrency, async target => {
193
+ const reportSettled = beginTarget?.(target);
194
+ const slice = await sweepTarget(ctx, options, target, attributeUnavailableProviders);
195
+ reportSettled?.(slice);
196
+ return slice;
123
197
  });
124
198
 
125
199
  const items: PullRequestShape[] = [];
@@ -2,12 +2,14 @@ import type { IntegrationIds } from '../constants.js';
2
2
  import type {
3
3
  IssueFilter,
4
4
  IssueSearchCapabilities,
5
+ IssueSorting,
5
6
  PullRequestFilter,
6
7
  PullRequestSearchCapabilities,
7
8
  } from '../providerFilters.js';
8
9
  import { providersMetadata } from '../providers/models.js';
9
10
  import type { ProviderWarning } from '../results.js';
10
11
  import type { IssueSearchCriteriaRejection, PullRequestSearchCriteriaRejection } from './filters.js';
12
+ import type { UnmergeableIssueSort, UnsupportedIssueSortRejection } from './ordering.js';
11
13
 
12
14
  /**
13
15
  * The warnings the provider facade raises on its OWN terms, as opposed to the ones derived from a caught
@@ -343,9 +345,71 @@ function describeIssueSearchCapabilities(capabilities: IssueSearchCapabilities):
343
345
  return [
344
346
  ...capabilities.relationships.map(r => `relationships:${r}`),
345
347
  ...flags.filter(([, supported]) => supported).map(([name]) => name),
348
+ ...capabilities.sorts.map(s => `sort:${s}`),
346
349
  ].join(', ');
347
350
  }
348
351
 
352
+ /**
353
+ * Warning for an issue read asked to order by a key its provider can't express server-side.
354
+ *
355
+ * Separate from {@link unsupportedIssueSearchCriteriaWarning} because it serves the reads that take `sort` as an
356
+ * OPTION rather than as a criterion — repo-scoped, account-wide, and issue-tracker — where there is no criteria
357
+ * set to fold it into. The filtered search keeps reporting it as one more `unsupported-criteria` entry, so a
358
+ * caller there still gets a single refusal listing everything at once.
359
+ *
360
+ * Takes the rejection the check produced rather than a re-read supported list: which of the two tables applies is
361
+ * the caller's own branch (`repos` present or not), so reading it back here — or threading it by hand — would be
362
+ * free to disagree with the check that refused.
363
+ */
364
+ export function unsupportedIssueSortWarning(
365
+ id: IntegrationIds,
366
+ domain: string | undefined,
367
+ connectionId: string | undefined,
368
+ rejection: UnsupportedIssueSortRejection,
369
+ ): ProviderWarning {
370
+ return otherWarning(
371
+ id,
372
+ domain,
373
+ connectionId,
374
+ unsupportedNarrowingMessage(id, 'issue sort', rejection.requested, (rejection.supported ?? []).join(', ')),
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Warning for a merged issue read asked to order by a key that no normalized issue carries.
380
+ *
381
+ * The distinction this exists to make is not about the provider: the provider orders by `priority` perfectly well
382
+ * within ONE project. It is about the read, which fans out over several and merges the results here, where the
383
+ * only fields available are the ones {@link IssueShape} models. Serving the concatenated per-scope runs would look
384
+ * ordered without being so, which is why this refuses instead — and why the message says WHY, since the same key
385
+ * against the same provider succeeds on a single-scope read.
386
+ *
387
+ * What a read fans out OVER differs by read, hence the `scope`: repositories, projects, or — where one query is
388
+ * issued per requested relationship and the runs are unioned within a single project (Jira) — the filters
389
+ * themselves. Naming the wrong one would tell a caller to narrow something that isn't what merged.
390
+ */
391
+ export function unmergeableIssueSortWarning(
392
+ id: IntegrationIds,
393
+ domain: string | undefined,
394
+ connectionId: string | undefined,
395
+ unmergeable: UnmergeableIssueSort,
396
+ scope: 'repositories' | 'projects' | 'filters',
397
+ ): ProviderWarning {
398
+ // Both forms are spelled out rather than depluralized at runtime: the message needs each, and
399
+ // `'repositories'.replace(/s$/, '')` is `'repositorie'`.
400
+ const nouns = {
401
+ repositories: { many: 'repositories', one: 'repository' },
402
+ projects: { many: 'projects', one: 'project' },
403
+ filters: { many: 'issue relationships', one: 'relationship' },
404
+ }[scope];
405
+ return otherWarning(
406
+ id,
407
+ domain,
408
+ connectionId,
409
+ `Cannot order by '${unmergeable.requested}' across several ${nouns.many}: this read merges their results, and a normalized issue carries no such field to merge on. Read one ${nouns.one} at a time, or order by a date, title, comment or reaction count.`,
410
+ );
411
+ }
412
+
349
413
  /**
350
414
  * The omission for a filtered issue search that hit the provider's RESULT CEILING: more issues matched than the
351
415
  * provider will ever serve for one query, however it is paged.
@@ -356,6 +420,10 @@ function describeIssueSearchCapabilities(capabilities: IssueSearchCapabilities):
356
420
  * 'none'` because the items past the ceiling are UNREACHABLE, not merely unfetched: no budget, no retry, and no
357
421
  * cursor would return them. Narrowing the criteria is the only way through, which is a decision for the caller.
358
422
  *
423
+ * The message names the ORDER, and has to now that ordering is an option: the reachable window is the top `limit`
424
+ * under that key, so "the 1.000 most recent" is a true sentence only while the key is the default one. The same
425
+ * value goes on the omission, so a consumer wording its own sentence doesn't have to remember what it asked for.
426
+ *
359
427
  * Returns `undefined` when the provider declares no ceiling, so the caller falls back to the generic wording
360
428
  * rather than reporting a limit it invented.
361
429
  */
@@ -364,18 +432,20 @@ export function issueSearchCapResultWarning(
364
432
  domain: string | undefined,
365
433
  connectionId: string | undefined,
366
434
  totalCount: number | undefined,
435
+ sort: IssueSorting,
367
436
  ): ProviderWarning | undefined {
368
437
  const limit = providersMetadata[id]?.issueSearchResultLimit;
369
438
  if (limit == null) return undefined;
370
439
  // Below the ceiling this warning would be a false claim; the caller's truncation had another cause.
371
440
  if (totalCount == null || totalCount <= limit) return undefined;
372
441
 
442
+ const [field, direction] = sort.split(':');
373
443
  return {
374
444
  ...otherWarning(
375
445
  id,
376
446
  domain,
377
447
  connectionId,
378
- `Issue search matched ${totalCount} results, but '${id}' serves at most ${limit}; narrow the search to read the rest.`,
448
+ `Issue search matched ${totalCount} results, but '${id}' serves at most ${limit}, ordered by ${field} ${direction}ending; narrow the search to read the rest.`,
379
449
  ),
380
450
  omission: {
381
451
  kind: 'provider-limit',
@@ -383,6 +453,7 @@ export function issueSearchCapResultWarning(
383
453
  recovery: 'none',
384
454
  limit: limit,
385
455
  totalCount: totalCount,
456
+ sort: sort,
386
457
  },
387
458
  };
388
459
  }
@@ -1,4 +1,5 @@
1
1
  import { AuthenticationError, RequestNotFoundError, RequestRateLimitError } from '../../git/errors.js';
2
+ import type { IssueSorting } from '../../git/models/issue.js';
2
3
  import type { IntegrationIds } from './constants.js';
3
4
 
4
5
  export interface ConnectionStateChangeEvent {
@@ -77,6 +78,15 @@ export interface ProviderWarningOmission {
77
78
  limit?: number;
78
79
  /** Total matches the provider reported, when it reports one. Only GitHub's search cap does today. */
79
80
  totalCount?: number;
81
+ /**
82
+ * The order the reachable window was selected under, as `field:direction`, when the omission is a result
83
+ * ceiling.
84
+ *
85
+ * Present so a consumer can word its own "N matched, showing the first M" without assuming WHICH M: the
86
+ * provider serves the top M under this key, so the sentence is only true if it names it. Absent for every
87
+ * omission that isn't a ceiling, where no window was selected by an order.
88
+ */
89
+ sort?: IssueSorting;
80
90
  /** Which repository / project / resource was affected, when one is attributed. */
81
91
  scope?: ProviderWarningOmissionScope;
82
92
  }