@gitkraken/core-gitlens 0.5.109 → 0.5.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/git/models/pullRequest.d.ts +81 -0
  3. package/dist/git/models/pullRequest.d.ts.map +1 -1
  4. package/dist/git/models/pullRequest.js +15 -0
  5. package/dist/git/models/pullRequest.js.map +1 -1
  6. package/dist/git/utils/pullRequest.utils.d.ts +11 -1
  7. package/dist/git/utils/pullRequest.utils.d.ts.map +1 -1
  8. package/dist/git/utils/pullRequest.utils.js +40 -0
  9. package/dist/git/utils/pullRequest.utils.js.map +1 -1
  10. package/dist/plus/git-github/api/github.d.ts +37 -4
  11. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  12. package/dist/plus/git-github/api/github.js +147 -16
  13. package/dist/plus/git-github/api/github.js.map +1 -1
  14. package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts +27 -1
  15. package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts.map +1 -1
  16. package/dist/plus/git-github/api/pullRequestSearchQuery.js +73 -6
  17. package/dist/plus/git-github/api/pullRequestSearchQuery.js.map +1 -1
  18. package/dist/plus/git-github/models.d.ts +17 -5
  19. package/dist/plus/git-github/models.d.ts.map +1 -1
  20. package/dist/plus/git-github/models.js +14 -1
  21. package/dist/plus/git-github/models.js.map +1 -1
  22. package/dist/plus/integrations/integrationService.d.ts +16 -1
  23. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  24. package/dist/plus/integrations/integrationService.js +9 -1
  25. package/dist/plus/integrations/integrationService.js.map +1 -1
  26. package/dist/plus/integrations/manager.d.ts +45 -5
  27. package/dist/plus/integrations/manager.d.ts.map +1 -1
  28. package/dist/plus/integrations/models/gitHostIntegration.d.ts +12 -0
  29. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  30. package/dist/plus/integrations/models/gitHostIntegration.js +18 -0
  31. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  32. package/dist/plus/integrations/models/integration.d.ts +1 -1
  33. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  34. package/dist/plus/integrations/models/integration.js.map +1 -1
  35. package/dist/plus/integrations/providerFilters.d.ts +1 -1
  36. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  37. package/dist/plus/integrations/providers/accounts.d.ts +6 -0
  38. package/dist/plus/integrations/providers/accounts.d.ts.map +1 -0
  39. package/dist/plus/integrations/providers/accounts.js +30 -0
  40. package/dist/plus/integrations/providers/accounts.js.map +1 -0
  41. package/dist/plus/integrations/providers/github.d.ts +9 -0
  42. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  43. package/dist/plus/integrations/providers/github.js +114 -135
  44. package/dist/plus/integrations/providers/github.js.map +1 -1
  45. package/dist/plus/integrations/providers/gitlab.d.ts +9 -0
  46. package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
  47. package/dist/plus/integrations/providers/gitlab.js +23 -4
  48. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  49. package/dist/plus/integrations/providers/models.d.ts +14 -32
  50. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  51. package/dist/plus/integrations/providers/models.js +36 -105
  52. package/dist/plus/integrations/providers/models.js.map +1 -1
  53. package/dist/plus/integrations/providers/pullRequestReviews.d.ts +50 -0
  54. package/dist/plus/integrations/providers/pullRequestReviews.d.ts.map +1 -0
  55. package/dist/plus/integrations/providers/pullRequestReviews.js +99 -0
  56. package/dist/plus/integrations/providers/pullRequestReviews.js.map +1 -0
  57. package/dist/plus/integrations/reads/counts.d.ts +55 -0
  58. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  59. package/dist/plus/integrations/reads/counts.js +146 -2
  60. package/dist/plus/integrations/reads/counts.js.map +1 -1
  61. package/dist/plus/integrations/reads/drains.d.ts +7 -1
  62. package/dist/plus/integrations/reads/drains.d.ts.map +1 -1
  63. package/dist/plus/integrations/reads/drains.js +13 -2
  64. package/dist/plus/integrations/reads/drains.js.map +1 -1
  65. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  66. package/dist/plus/integrations/reads/filters.js +22 -0
  67. package/dist/plus/integrations/reads/filters.js.map +1 -1
  68. package/dist/plus/integrations/reads/searchPullRequests.d.ts +4 -3
  69. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
  70. package/dist/plus/integrations/reads/searchPullRequests.js +6 -4
  71. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
  72. package/dist/plus/integrations/reads/sweeps.js +1 -1
  73. package/dist/plus/integrations/reads/sweeps.js.map +1 -1
  74. package/dist/plus/integrations/reads/warnings.d.ts +2 -2
  75. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  76. package/dist/plus/integrations/reads/warnings.js +11 -2
  77. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  78. package/dist/plus/integrations/results.d.ts +6 -1
  79. package/dist/plus/integrations/results.d.ts.map +1 -1
  80. package/dist/plus/integrations/results.js.map +1 -1
  81. package/docs/integrations.md +76 -10
  82. package/docs/kepler-read-api-parity.md +9 -0
  83. package/package.json +1 -1
  84. package/src/git/models/pullRequest.ts +84 -0
  85. package/src/git/utils/pullRequest.utils.ts +50 -0
  86. package/src/plus/git-github/api/github.ts +176 -16
  87. package/src/plus/git-github/api/pullRequestSearchQuery.ts +81 -7
  88. package/src/plus/git-github/models.ts +29 -5
  89. package/src/plus/integrations/integrationService.ts +25 -2
  90. package/src/plus/integrations/manager.ts +50 -5
  91. package/src/plus/integrations/models/gitHostIntegration.ts +29 -0
  92. package/src/plus/integrations/models/integration.ts +1 -0
  93. package/src/plus/integrations/providerFilters.ts +6 -1
  94. package/src/plus/integrations/providers/accounts.ts +34 -0
  95. package/src/plus/integrations/providers/github.ts +133 -144
  96. package/src/plus/integrations/providers/gitlab.ts +28 -10
  97. package/src/plus/integrations/providers/models.ts +64 -124
  98. package/src/plus/integrations/providers/pullRequestReviews.ts +122 -0
  99. package/src/plus/integrations/reads/counts.ts +273 -2
  100. package/src/plus/integrations/reads/drains.ts +12 -2
  101. package/src/plus/integrations/reads/filters.ts +22 -0
  102. package/src/plus/integrations/reads/searchPullRequests.ts +12 -4
  103. package/src/plus/integrations/reads/sweeps.ts +1 -1
  104. package/src/plus/integrations/reads/warnings.ts +12 -1
  105. package/src/plus/integrations/results.ts +6 -1
@@ -1,4 +1,5 @@
1
1
  import type { IssueSearchCriteria } from '../../../git/models/issue.js';
2
+ import type { PullRequestSearchCriteria } from '../../../git/models/pullRequest.js';
2
3
  import { chunk } from '../../../utils/array.js';
3
4
  import { mapBounded } from '../../../utils/promise.js';
4
5
  import type { IntegrationIds } from '../constants.js';
@@ -14,12 +15,18 @@ import {
14
15
  } from '../utils/integration.utils.js';
15
16
  import type { ProviderReadContext } from './context.js';
16
17
  import { runCaptured } from './drains.js';
17
- import { resolveIssueSearchCriteria, resolveIssueSearchScope } from './filters.js';
18
+ import {
19
+ resolveIssueSearchCriteria,
20
+ resolveIssueSearchScope,
21
+ resolvePullRequestSearchCriteria,
22
+ resolvePullRequestSearchScope,
23
+ } from './filters.js';
18
24
  import {
19
25
  gitHostOnlySurfaceWarning,
20
26
  issuesUnsupportedWarning,
21
27
  otherWarning,
22
28
  unsupportedIssueSearchCriteriaWarning,
29
+ unsupportedPullRequestSearchCriteriaWarning,
23
30
  } from './warnings.js';
24
31
 
25
32
  /**
@@ -32,6 +39,15 @@ import {
32
39
  */
33
40
  const issueCountChunkSize = 25;
34
41
 
42
+ /**
43
+ * How many pull-request scopes go into one upstream request.
44
+ *
45
+ * The same latency/complexity reasoning as {@link issueCountChunkSize}, kept slightly smaller because a pull-request
46
+ * scope can fan out into one aliased count PER requested state (open/closed/merged), so a chunk of scopes carries a
47
+ * small multiple of that many `search` aliases.
48
+ */
49
+ const pullRequestCountChunkSize = 15;
50
+
35
51
  /**
36
52
  * One scope to count.
37
53
  *
@@ -73,6 +89,42 @@ export interface IssueCountResult {
73
89
  providerLimit?: number;
74
90
  }
75
91
 
92
+ /**
93
+ * One scope to count pull requests over — the PR twin of {@link IssueCountScope}.
94
+ *
95
+ * `key` is caller-owned and echoed back verbatim, never reaching the provider query (the aliases are generated), so
96
+ * it can be any string the caller finds meaningful.
97
+ */
98
+ export interface PullRequestCountScope {
99
+ key: string;
100
+ /** Repositories to count over. Combines with `org`, exactly as in `searchPullRequestsPage`. */
101
+ repos?: ProviderReposInput;
102
+ org?: string;
103
+ /**
104
+ * Same criteria model as `searchPullRequestsPage`, validated against the same capability table — so a count
105
+ * refuses exactly what the read would, and never previews a fetch that can't happen.
106
+ */
107
+ criteria?: PullRequestSearchCriteria;
108
+ }
109
+
110
+ /** The count for one {@link PullRequestCountScope}, echoed back under the caller's own `key`. */
111
+ export interface PullRequestCountResult {
112
+ key: string;
113
+ /**
114
+ * Total matches the provider reports. `undefined` when the provider didn't report one for this scope — NEVER
115
+ * zero, which is a real answer. For a multi-state scope this is the LARGEST of its per-state counts, the same
116
+ * total `searchPullRequestsPage` surfaces, not their sum.
117
+ */
118
+ count?: number;
119
+ /**
120
+ * True when `count` exceeds the provider's own per-search result ceiling, so a full read CANNOT return
121
+ * everything no matter how it is paged. This is the signal to warn before starting an expensive fetch.
122
+ */
123
+ exceedsProviderLimit: boolean;
124
+ /** The ceiling itself, when the provider declares one. */
125
+ providerLimit?: number;
126
+ }
127
+
76
128
  /**
77
129
  * Counts issues for many scopes without fetching any — the probe behind a "this will fetch ~N issues" preview.
78
130
  *
@@ -228,7 +280,7 @@ export async function countIssues(
228
280
  }
229
281
 
230
282
  /** The first key that appears twice, or `undefined` when every key is unique. */
231
- function findDuplicateKey(scopes: readonly IssueCountScope[]): string | undefined {
283
+ function findDuplicateKey(scopes: readonly { key: string }[]): string | undefined {
232
284
  const seen = new Set<string>();
233
285
  for (const scope of scopes) {
234
286
  if (seen.has(scope.key)) return scope.key;
@@ -290,3 +342,222 @@ function rejectScope(
290
342
 
291
343
  return undefined;
292
344
  }
345
+
346
+ /**
347
+ * Counts pull requests for many scopes without fetching any — the PR twin of {@link countIssues}, and the probe
348
+ * behind a "this will fetch ~N pull requests" preview.
349
+ *
350
+ * Identical batching, per-scope isolation, and `key`-echo contract as {@link countIssues}; see it for the cost
351
+ * model and the `count: undefined` ≠ zero rule. The one difference is inherent to pull requests: a scope's criteria
352
+ * can name several STATES, which the provider counts as independent searches — the reported `count` is the LARGEST
353
+ * of them (mirroring `searchPullRequestsPage`'s total), so an unpaged read still can't exceed a single search's
354
+ * ceiling undetected.
355
+ */
356
+ export async function countPullRequests(
357
+ ctx: ProviderReadContext,
358
+ options: {
359
+ providerId: IntegrationIds;
360
+ scopes: readonly PullRequestCountScope[];
361
+ connectionId?: string;
362
+ /**
363
+ * Explicit self-managed host domain. Used only when the requested connection has no configured domain;
364
+ * it must come from the trusted authentication configuration, not repository or remote data.
365
+ */
366
+ domain?: string;
367
+ },
368
+ ): Promise<ProviderResult<PullRequestCountResult>> {
369
+ const refused = (warning: ProviderWarning): ProviderResult<PullRequestCountResult> => ({
370
+ items: [],
371
+ warnings: [warning],
372
+ fetchFailed: true,
373
+ });
374
+
375
+ if (isIssuesHostIntegrationId(options.providerId)) {
376
+ return refused(
377
+ gitHostOnlySurfaceWarning(options.providerId, undefined, options.connectionId, 'Pull request counts'),
378
+ );
379
+ }
380
+
381
+ // Nothing was asked for, so nothing is missing: an empty success, not a refusal.
382
+ if (options.scopes.length === 0) return { items: [], warnings: [] };
383
+
384
+ const duplicateKey = findDuplicateKey(options.scopes);
385
+ if (duplicateKey != null) {
386
+ // Refuses the whole call rather than deduping: `key` exists so the caller can match results without
387
+ // positional bookkeeping, and two results under one key make that ambiguous for EVERY scope, not just the
388
+ // repeated one.
389
+ return refused(
390
+ otherWarning(
391
+ options.providerId,
392
+ undefined,
393
+ options.connectionId,
394
+ `Duplicate pull request count scope key '${duplicateKey}'; keys identify results, so each must be unique.`,
395
+ ),
396
+ );
397
+ }
398
+
399
+ const integration = await ctx.getIntegrationForRead(options.providerId, options.connectionId, options.domain);
400
+ if (integration == null) {
401
+ // A supplied connection or domain that no longer resolves is a broken target, not an empty account.
402
+ const early = ctx.earlyReturnConnectionWarnings(options.providerId, options.connectionId, options.domain);
403
+ return { items: [], warnings: early.warnings, fetchFailed: early.fetchFailed || undefined };
404
+ }
405
+ if (!isGitHostIntegration(integration)) {
406
+ return refused(
407
+ gitHostOnlySurfaceWarning(options.providerId, undefined, options.connectionId, 'Pull request counts'),
408
+ );
409
+ }
410
+
411
+ const domain = ctx.domainForRead(integration, options.providerId, options.connectionId, options.domain);
412
+ const warnOnMissingSession = warnOnMissingSessionForDomain(options.providerId, options.domain);
413
+
414
+ // A provider with no filtered pull-request search has no count either: refuse ONCE for the provider rather than
415
+ // letting every scope repeat the same rejection. `undefined` criteria probes only the search's existence.
416
+ const searchSupport = resolvePullRequestSearchCriteria(options.providerId, undefined);
417
+ if (searchSupport.rejection != null) {
418
+ return refused(
419
+ unsupportedPullRequestSearchCriteriaWarning(
420
+ options.providerId,
421
+ domain,
422
+ options.connectionId,
423
+ searchSupport.rejection,
424
+ ),
425
+ );
426
+ }
427
+
428
+ const providerLimit = providersMetadata[options.providerId]?.pullRequestSearchResultLimit;
429
+ const warnings: ProviderWarning[] = [];
430
+ let fetchFailed = false;
431
+
432
+ // Validate every scope first, so a refusal costs no request at all and the countable ones are still batched
433
+ // together.
434
+ const countable: PullRequestCountScope[] = [];
435
+ for (const scope of options.scopes) {
436
+ const warning = rejectPullRequestScope(options.providerId, domain, options.connectionId, scope);
437
+ if (warning != null) {
438
+ // `push`, not `appendDedupedWarning`: every rejection message embeds the scope's own key, and duplicate
439
+ // keys were already refused above, so no two of these can ever collapse.
440
+ warnings.push(warning);
441
+ fetchFailed = true;
442
+ continue;
443
+ }
444
+
445
+ countable.push(scope);
446
+ }
447
+
448
+ // Chunks are independent requests over their own slice of scopes, `runCaptured` never throws, so they run
449
+ // concurrently, bounded like every other fan-out on the facade. `mapBounded` returns in input order, so `items`
450
+ // and `warnings` stay in scope order.
451
+ const batches = await mapBounded(chunk(countable, pullRequestCountChunkSize), providerFanOutConcurrency, batch =>
452
+ runCaptured(
453
+ options.providerId,
454
+ domain,
455
+ options.connectionId,
456
+ () =>
457
+ integration.countPullRequestsResult(
458
+ batch.map(s => ({
459
+ repos: s.repos as ProviderRepoInput[] | undefined,
460
+ org: s.org,
461
+ criteria: s.criteria,
462
+ })),
463
+ undefined,
464
+ options.connectionId,
465
+ ),
466
+ { warnOnMissingSession: warnOnMissingSession },
467
+ ).then(result => ({ batch: batch, ...result })),
468
+ );
469
+
470
+ const items: PullRequestCountResult[] = [];
471
+ for (const { batch, value, warning } of batches) {
472
+ if (warning != null) {
473
+ appendDedupedWarning(warnings, warning);
474
+ }
475
+ if (value == null) {
476
+ // This batch contributes nothing, but the batches around it still do. Drop only these scopes.
477
+ fetchFailed = true;
478
+ continue;
479
+ }
480
+
481
+ for (let i = 0; i < batch.length; i++) {
482
+ const count = value[i];
483
+ items.push({
484
+ key: batch[i].key,
485
+ count: count,
486
+ // Only a reported count can exceed a declared ceiling; unknown-vs-limit is not a comparison.
487
+ exceedsProviderLimit: count != null && providerLimit != null && count > providerLimit,
488
+ providerLimit: providerLimit,
489
+ });
490
+ }
491
+ }
492
+
493
+ // A provider that reported nothing for any scope and raised no warning: say so explicitly rather than letting it
494
+ // read as "every scope matched nothing".
495
+ if (items.length === 0 && warnings.length === 0) {
496
+ return refused(
497
+ unsupportedPullRequestSearchCriteriaWarning(options.providerId, domain, options.connectionId, {
498
+ reason: 'unsupported-search',
499
+ }),
500
+ );
501
+ }
502
+
503
+ return { items: items, warnings: warnings, fetchFailed: fetchFailed || undefined };
504
+ }
505
+
506
+ /**
507
+ * Why one pull-request scope can't be counted, as the warning to report — or `undefined` when it can. The PR twin
508
+ * of {@link rejectScope}, validating against the same {@link resolvePullRequestSearchScope} /
509
+ * {@link resolvePullRequestSearchCriteria} the read uses, so a count always previews the constraints the read
510
+ * would apply.
511
+ */
512
+ function rejectPullRequestScope(
513
+ providerId: IntegrationIds,
514
+ domain: string | undefined,
515
+ connectionId: string | undefined,
516
+ scope: PullRequestCountScope,
517
+ ): ProviderWarning | undefined {
518
+ const scoping = resolvePullRequestSearchScope(providerId, scope.repos, scope.org, scope.criteria);
519
+ switch (scoping.rejection) {
520
+ case 'repo-ids':
521
+ return otherWarning(
522
+ providerId,
523
+ domain,
524
+ connectionId,
525
+ `Pull request count scope '${scope.key}' cannot be scoped by repository id; pass repository descriptors (namespace + name) instead.`,
526
+ );
527
+ case 'unscoped':
528
+ return otherWarning(
529
+ providerId,
530
+ domain,
531
+ connectionId,
532
+ `Pull request count scope '${scope.key}' is unscoped; pass \`repos\`, \`org\`, or at least one current-user relationship.`,
533
+ );
534
+ case 'unsupported-repository-scope':
535
+ case 'unsupported-organization-scope':
536
+ return unsupportedPullRequestSearchCriteriaWarning(providerId, domain, connectionId, {
537
+ reason: 'unsupported-criteria',
538
+ criteria: [
539
+ scoping.rejection === 'unsupported-repository-scope' ? 'repositoryScope' : 'organizationScope',
540
+ ],
541
+ });
542
+ }
543
+
544
+ const resolved = resolvePullRequestSearchCriteria(providerId, scope.criteria);
545
+ if (resolved.rejection != null) {
546
+ return unsupportedPullRequestSearchCriteriaWarning(providerId, domain, connectionId, resolved.rejection);
547
+ }
548
+
549
+ // Count-only, with no counterpart in the read: a relationship set is an OR across several searches, which one
550
+ // count can't express — summing would double-count overlaps and max would under-report. Ask the caller to count
551
+ // each relationship as its own scope, where the keys make the OR explicit. (States are disjoint, so a scope may
552
+ // still name several — the provider counts them as the max, not a refusal.)
553
+ if ((scope.criteria?.relationships?.length ?? 0) > 1) {
554
+ return otherWarning(
555
+ providerId,
556
+ domain,
557
+ connectionId,
558
+ `Pull request count scope '${scope.key}' requests several relationships, which a single count can't express (they are OR-ed, so overlapping matches would be double-counted); pass one scope per relationship.`,
559
+ );
560
+ }
561
+
562
+ return undefined;
563
+ }
@@ -70,7 +70,12 @@ export async function drainPullRequests(
70
70
  repos: ProviderReposInput,
71
71
  state: PullRequestStateFilter[] | undefined,
72
72
  filters: PullRequestFilter[] | undefined,
73
- includeReviewRequested: boolean,
73
+ /**
74
+ * Breadth opt-ins that only the account-wide read can express. Grouped rather than passed positionally
75
+ * because they are same-typed neighbors a transposition would silently swap, and picked from the sweep
76
+ * options rather than restated so a third opt-in is declared once.
77
+ */
78
+ accountWideOptions: Pick<PullRequestSweepOptions, 'includeReviewRequested' | 'includeReviews'> | undefined,
74
79
  connectionId: string | undefined,
75
80
  maxPages: number,
76
81
  attributeUnavailableProvider: boolean,
@@ -100,6 +105,10 @@ export async function drainPullRequests(
100
105
  // With no repos this is an account-wide "my PRs" sweep. The repo-scoped core rejects an empty `repos`
101
106
  // input, so read the provider-native account-wide core instead.
102
107
  const accountWide = repos.length === 0;
108
+ // The breadth opt-ins are only expressible on the account-wide core, so gate them here — the single place
109
+ // that already knows which core it is about to call.
110
+ const includeReviewRequested = accountWide && (accountWideOptions?.includeReviewRequested ?? false);
111
+ const includeReviews = accountWide && (accountWideOptions?.includeReviews ?? false);
103
112
  /** Every cursor already followed, so a provider that cycles them can't keep the drain walking in circles. */
104
113
  const seenCursors = new Set<string>();
105
114
 
@@ -115,7 +124,8 @@ export async function drainPullRequests(
115
124
  cursor: pageCursor,
116
125
  includeReviewRequested: includeReviewRequested,
117
126
  filters: filters,
118
- summary: true,
127
+ // Lite by default; the full projection (reviews) only when a caller opts in.
128
+ summary: !includeReviews,
119
129
  },
120
130
  connectionId,
121
131
  )
@@ -104,9 +104,25 @@ export function resolvePullRequestSearchCriteria(
104
104
  if (criteria.text != null && criteria.text.trim().length > 0 && !supported.text) {
105
105
  unsupported.push('text');
106
106
  }
107
+ if (criteria.updatedAfter != null && !supported.updatedAfter) {
108
+ unsupported.push('updatedAfter');
109
+ }
110
+ if (criteria.createdAfter != null && !supported.createdAfter) {
111
+ unsupported.push('createdAfter');
112
+ }
107
113
  if (criteria.includeArchived === true && !supported.includeArchived) {
108
114
  unsupported.push('includeArchived');
109
115
  }
116
+ // `!= null`, not truthy: `draft: false` (only ready-for-review) is as much a request as `draft: true`.
117
+ if (criteria.draft != null && !supported.draft) {
118
+ unsupported.push('draft');
119
+ }
120
+ // Folded into the same `unsupported-criteria` rejection rather than its own reason, so a caller asking for one
121
+ // inexpressible filter and one inexpressible sort learns about both at once. `updated:desc` is always in
122
+ // `sorts` for a usable search, so the omitted (default) case never rejects.
123
+ if (criteria.sort != null && !supported.sorts.includes(criteria.sort)) {
124
+ unsupported.push(`sort:${criteria.sort}`);
125
+ }
110
126
 
111
127
  return unsupported.length > 0 ? { rejection: { reason: 'unsupported-criteria', criteria: unsupported } } : {};
112
128
  }
@@ -279,9 +295,13 @@ const unsupportedPullRequestSearchCapabilities: PullRequestSearchCapabilities =
279
295
  relationships: [],
280
296
  states: [],
281
297
  text: false,
298
+ updatedAfter: false,
299
+ createdAfter: false,
282
300
  includeArchived: false,
301
+ draft: false,
283
302
  repositoryScope: false,
284
303
  organizationScope: false,
304
+ sorts: [],
285
305
  };
286
306
 
287
307
  /** Why a filtered issue search's scope was refused, or `undefined` when it is usable. */
@@ -428,6 +448,8 @@ export function getSupportedFilters(providerId: IntegrationIds): SupportedFilter
428
448
  ...pullRequestSearch,
429
449
  relationships: [...(pullRequestSearch?.relationships ?? [])],
430
450
  states: [...(pullRequestSearch?.states ?? [])],
451
+ // Copied, so mutating the result can't corrupt the metadata table.
452
+ sorts: [...(pullRequestSearch?.sorts ?? [])],
431
453
  },
432
454
  issues: [...(metadata?.supportedIssueFilters ?? [])],
433
455
  issuesAccountWide: [...(metadata?.supportedAccountWideIssueFilters ?? [])],
@@ -1,4 +1,5 @@
1
1
  import type { PullRequestSearchCriteria, PullRequestShape } from '../../../git/models/pullRequest.js';
2
+ import { defaultPullRequestSort } from '../../../git/models/pullRequest.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';
@@ -29,9 +30,10 @@ import {
29
30
  * Pull requests matching structured criteria over a repository/organization or current-user relationship scope.
30
31
  *
31
32
  * The read is deliberately separate from `listPullRequestsPage`: the list has provider-specific "my PRs"
32
- * semantics and no text channel, while this method promises a sanitized query, most-recently-updated-first
33
- * ordering, one upstream request per cursor-threaded page, and a quantified omission at the provider's result
34
- * ceiling.
33
+ * semantics and no text channel, while this method promises a sanitized query, an ordering the caller chooses
34
+ * (`criteria.sort`, defaulting to most-recently-updated-first) and that is applied to the merged page as a whole,
35
+ * one upstream request per cursor-threaded page, and a quantified omission at the provider's result ceiling that
36
+ * names the order the reachable window was selected under.
35
37
  */
36
38
  export async function searchPullRequestsPage(
37
39
  ctx: ProviderReadContext,
@@ -183,7 +185,13 @@ export async function searchPullRequestsPage(
183
185
  const truncated = continuation.truncated;
184
186
  if (truncated && warnings.length === 0) {
185
187
  warnings.push(
186
- pullRequestSearchCapResultWarning(options.providerId, domain, options.connectionId, totalCount) ??
188
+ pullRequestSearchCapResultWarning(
189
+ options.providerId,
190
+ domain,
191
+ options.connectionId,
192
+ totalCount,
193
+ options.criteria?.sort ?? defaultPullRequestSort,
194
+ ) ??
187
195
  truncationWarning(options.providerId, domain, options.connectionId, 'Pull request search', 'exhausted'),
188
196
  );
189
197
  }
@@ -111,7 +111,7 @@ async function sweepTarget(
111
111
  repos,
112
112
  options?.states,
113
113
  resolved.filters,
114
- accountWide ? (options?.includeReviewRequested ?? false) : false,
114
+ options,
115
115
  connectionId,
116
116
  maxPages,
117
117
  attributeUnavailableProviders,
@@ -5,6 +5,7 @@ import type {
5
5
  IssueSorting,
6
6
  PullRequestFilter,
7
7
  PullRequestSearchCapabilities,
8
+ PullRequestSorting,
8
9
  } from '../providerFilters.js';
9
10
  import { providersMetadata } from '../providers/models.js';
10
11
  import type { ProviderWarning } from '../results.js';
@@ -286,9 +287,13 @@ function describePullRequestSearchCapabilities(capabilities: PullRequestSearchCa
286
287
  ...(capabilities.relationships.length ? [`relationships:${capabilities.relationships.join('|')}`] : []),
287
288
  ...(capabilities.states.length ? [`states:${capabilities.states.join('|')}`] : []),
288
289
  ...(capabilities.text ? ['text'] : []),
290
+ ...(capabilities.updatedAfter ? ['updatedAfter'] : []),
291
+ ...(capabilities.createdAfter ? ['createdAfter'] : []),
289
292
  ...(capabilities.includeArchived ? ['includeArchived'] : []),
293
+ ...(capabilities.draft ? ['draft'] : []),
290
294
  ...(capabilities.repositoryScope ? ['repository scope'] : []),
291
295
  ...(capabilities.organizationScope ? ['organization scope'] : []),
296
+ ...(capabilities.sorts.length ? [`sorts:${capabilities.sorts.join('|')}`] : []),
292
297
  ].join(', ');
293
298
  }
294
299
 
@@ -464,22 +469,28 @@ export function pullRequestSearchCapResultWarning(
464
469
  domain: string | undefined,
465
470
  connectionId: string | undefined,
466
471
  totalCount: number | undefined,
472
+ sort: PullRequestSorting,
467
473
  ): ProviderWarning | undefined {
468
474
  const limit = providersMetadata[id]?.pullRequestSearchResultLimit;
469
475
  if (limit == null || totalCount == null || totalCount <= limit) return undefined;
470
476
 
477
+ // Name the order: the reachable window is the first `limit` under THIS sort, and a different key reaches a
478
+ // different subset, so a ceiling message that omits it can't say which slice was served. Same reasoning as
479
+ // `issueSearchCapResultWarning`.
480
+ const [field, direction] = sort.split(':');
471
481
  return {
472
482
  ...otherWarning(
473
483
  id,
474
484
  domain,
475
485
  connectionId,
476
- `Pull request search matched ${totalCount} results, but '${id}' serves at most ${limit}; narrow the search to read the rest.`,
486
+ `Pull request search matched ${totalCount} results, but '${id}' serves at most ${limit}, ordered by ${field} ${direction}ending; narrow the search to read the rest.`,
477
487
  ),
478
488
  omission: {
479
489
  kind: 'provider-limit',
480
490
  recovery: 'none',
481
491
  limit: limit,
482
492
  totalCount: totalCount,
493
+ sort: sort,
483
494
  },
484
495
  };
485
496
  }
@@ -1,5 +1,6 @@
1
1
  import { AuthenticationError, RequestNotFoundError, RequestRateLimitError } from '../../git/errors.js';
2
2
  import type { IssueSorting } from '../../git/models/issue.js';
3
+ import type { PullRequestSorting } from '../../git/models/pullRequest.js';
3
4
  import type { IntegrationIds } from './constants.js';
4
5
 
5
6
  export interface ConnectionStateChangeEvent {
@@ -85,8 +86,12 @@ export interface ProviderWarningOmission {
85
86
  * Present so a consumer can word its own "N matched, showing the first M" without assuming WHICH M: the
86
87
  * provider serves the top M under this key, so the sentence is only true if it names it. Absent for every
87
88
  * omission that isn't a ceiling, where no window was selected by an order.
89
+ *
90
+ * A union of both vocabularies rather than the issue one alone: `PullRequestSorting` happens to be a subset
91
+ * today, so narrowing this to {@link IssueSorting} would still compile while describing only half the reads
92
+ * that set it — and would break the day the PR search gains a key the issue search has no name for.
88
93
  */
89
- sort?: IssueSorting;
94
+ sort?: IssueSorting | PullRequestSorting;
90
95
  /** Which repository / project / resource was affected, when one is attributed. */
91
96
  scope?: ProviderWarningOmissionScope;
92
97
  }