@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
@@ -4,9 +4,58 @@ import type {
4
4
  PullRequestRefs,
5
5
  PullRequestRepositoryIdentityDescriptor,
6
6
  PullRequestShape,
7
+ PullRequestSortField,
8
+ PullRequestSorting,
7
9
  } from '../models/pullRequest.js';
8
10
  import { shortenRevision } from './revision.utils.js';
9
11
 
12
+ /**
13
+ * How each sort field is read off a {@link PullRequestShape}, for the filtered PR search, which MERGES its
14
+ * relationship × state facets in the facade and so has to order the union itself instead of trusting the
15
+ * per-facet server order. Only the fields the shape carries appear — the same rule as `getIssueComparator`, and
16
+ * the reason {@link PullRequestSortField} is just these two.
17
+ */
18
+ const pullRequestSortValues: Partial<
19
+ Record<PullRequestSortField, (pr: PullRequestShape) => number | Date | string | undefined>
20
+ > = {
21
+ created: pr => pr.createdDate,
22
+ updated: pr => pr.updatedDate,
23
+ };
24
+
25
+ /**
26
+ * A comparator for one sort key over normalized pull requests, or `undefined` when the field isn't derivable
27
+ * from a {@link PullRequestShape} — the signal that the key is only honorable on a SINGLE-origin read where the
28
+ * provider already ordered the page. Missing values sort LAST in both directions (partitioned before any
29
+ * arithmetic so two missing values compare equal, exactly as `getIssueComparator` does).
30
+ *
31
+ * Note it orders whatever it is handed. Sorting a page already capped at the result ceiling does not make it the
32
+ * top N.
33
+ */
34
+ export function getPullRequestComparator(
35
+ sort: PullRequestSorting,
36
+ ): ((a: PullRequestShape, b: PullRequestShape) => number) | undefined {
37
+ const [field, direction] = sort.split(':') as [PullRequestSortField, 'asc' | 'desc'];
38
+ const getValue = pullRequestSortValues[field];
39
+ if (getValue == null) return undefined;
40
+
41
+ return (a, b) => {
42
+ const left = toComparable(getValue(a));
43
+ const right = toComparable(getValue(b));
44
+ if (left == null || right == null) {
45
+ if (left == null && right == null) return 0;
46
+
47
+ return left == null ? 1 : -1;
48
+ }
49
+
50
+ const ordered = left < right ? -1 : left > right ? 1 : 0;
51
+ return direction === 'asc' ? ordered : -ordered;
52
+ };
53
+ }
54
+
55
+ function toComparable(value: number | Date | string | undefined): number | string | undefined {
56
+ return value instanceof Date ? value.getTime() : value;
57
+ }
58
+
10
59
  export interface PullRequestUrlIdentity<TProvider extends string = string> {
11
60
  provider?: TProvider;
12
61
 
@@ -143,6 +192,7 @@ export function serializePullRequest(value: PullRequest): PullRequestShape {
143
192
  thumbsUpCount: value.thumbsUpCount,
144
193
  reviewDecision: value.reviewDecision,
145
194
  reviewRequests: value.reviewRequests,
195
+ latestReviews: value.latestReviews,
146
196
  assignees: value.assignees,
147
197
  authoredByMe: value.authoredByMe,
148
198
  project: value.project
@@ -21,7 +21,7 @@ import type {
21
21
  PullRequestState,
22
22
  PullRequestStateFilter,
23
23
  } from '../../../git/models/pullRequest.js';
24
- import { PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
24
+ import { defaultPullRequestSort, PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
25
25
  import type { Provider } from '../../../git/models/remoteProvider.js';
26
26
  import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata.js';
27
27
  import type { GitRevisionRange } from '../../../git/models/revision.js';
@@ -29,6 +29,7 @@ import type { GitUser } from '../../../git/models/user.js';
29
29
  import type { RepositoryVisibility } from '../../../git/providers/types.js';
30
30
  import { getGitHubNoReplyAddressParts } from '../../../git/remotes/github.js';
31
31
  import { effectiveIssueSort, getIssueComparator } from '../../../git/utils/issue.utils.js';
32
+ import { getPullRequestComparator } from '../../../git/utils/pullRequest.utils.js';
32
33
  import {
33
34
  createRevisionRange,
34
35
  getRevisionRangeParts,
@@ -110,10 +111,17 @@ const accountResolveBatchSize = 25;
110
111
 
111
112
  // Pull-request search selects the full PR fragment (reviews, requests, refs, commits, etc.),
112
113
  // which makes GitHub reject broad 100-node searches with `Resource limits for this query
113
- // exceeded` on large repositories. Thirty keeps the default within that GraphQL cost budget
114
- // while callers that know their scope is cheap can still opt into the supported 100-node max.
114
+ // exceeded` on large repositories. Thirty keeps the default within that GraphQL cost budget;
115
+ // the 100-node maximum the connection accepts is for the lite shape, whose per-node cost is a
116
+ // fraction of it. The budget is per DOCUMENT, and one document holds every relationship × state
117
+ // facet, so the worst case scales with the facet count (five relationships × four states = 20).
115
118
  const defaultPullRequestSearchPageSize = 30;
116
119
  const maxPullRequestSearchPageSize = 100;
120
+ // Pages `searchMyPullRequests` drains for a caller that wants a whole list rather than a page.
121
+ // Four reduced pages cover more than the single 100-node page this read served before it paged,
122
+ // so the smaller page costs coverage nowhere; it bounds an unbounded drain on a read whose
123
+ // consumers (Launchpad, the Graph pull-request panel) block on it.
124
+ const maxMyPullRequestSearchPages = 4;
117
125
 
118
126
  function isRetryableTransientError(ex: unknown): ex is RequestError {
119
127
  // An aborted request is rethrown as the original `AbortError` (not a `RequestError`), so it is
@@ -259,6 +267,24 @@ repository {
259
267
  viewerPermission
260
268
  }
261
269
  `;
270
+ /**
271
+ * One review's fields, shared by `latestReviews` and `viewerLatestReview`. The two selections must stay
272
+ * identical: `fromGitHubPullRequest` merges the viewer's review into the capped `latestReviews` window and
273
+ * dedups by `id`, so a field present in only one of them would produce rows that differ by where they came
274
+ * from rather than by what they are.
275
+ */
276
+ const gqlPullRequestReviewFragment = `
277
+ id
278
+ author {
279
+ login
280
+ avatarUrl(size: $avatarSize)
281
+ url
282
+ }
283
+ state
284
+ commit {
285
+ oid
286
+ }
287
+ `;
262
288
  const gqlPullRequestFragment = `
263
289
  ${gqlPullRequestLiteFragment}
264
290
  additions
@@ -278,14 +304,12 @@ mergedBy {
278
304
  reviewDecision
279
305
  latestReviews(first: 25) {
280
306
  nodes {
281
- author {
282
- login
283
- avatarUrl(size: $avatarSize)
284
- url
285
- }
286
- state
307
+ ${gqlPullRequestReviewFragment}
287
308
  }
288
309
  }
310
+ viewerLatestReview {
311
+ ${gqlPullRequestReviewFragment}
312
+ }
289
313
  reviewRequests(first: 25) {
290
314
  nodes {
291
315
  asCodeOwner
@@ -3526,8 +3550,10 @@ export class GitHubApi {
3526
3550
  * One page of the current user's pull requests, filtered by state and optionally by an explicit
3527
3551
  * relationship qualifier. Backs the PR sweeps, which drain it page by page.
3528
3552
  *
3529
- * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first),
3530
- * matching {@link searchPullRequestsPage}. A caller that stops before `hasMore` clears every sweep with a
3553
+ * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first), which is
3554
+ * {@link searchPullRequestsPage}'s DEFAULT rather than its only orderthat read takes a `criteria.sort`, this
3555
+ * one does not, because a sweep's recency window is what its page budget is defined against. A caller that stops
3556
+ * before `hasMore` clears — every sweep with a
3531
3557
  * page budget — therefore retains a well-defined recency window instead of an arbitrary slice of GitHub's
3532
3558
  * relevance ranking.
3533
3559
  */
@@ -3558,7 +3584,15 @@ export class GitHubApi {
3558
3584
  cancellation?: AbortSignal,
3559
3585
  ): Promise<{ values: PullRequest[]; cursor?: string; hasMore: boolean; truncated: boolean }> {
3560
3586
  const scope = getScopedLogger();
3561
- const limit = Math.min(100, this.config.getLaunchpadQueryLimit?.() ?? 100);
3587
+ // The page follows the projection rather than being a separate decision: the full fragment is what GitHub
3588
+ // rejects at 100 nodes (see `defaultPullRequestSearchPageSize`), so every read that selects it pages at the
3589
+ // reduced size, and only the lite shape asks for the maximum. A caller that wants a whole list pages for it
3590
+ // — see `searchMyPullRequests`.
3591
+ const configuredLimit = this.config.getLaunchpadQueryLimit?.() ?? maxPullRequestSearchPageSize;
3592
+ const limit = Math.min(
3593
+ options?.summary === true ? maxPullRequestSearchPageSize : defaultPullRequestSearchPageSize,
3594
+ configuredLimit,
3595
+ );
3562
3596
 
3563
3597
  try {
3564
3598
  interface SearchResult {
@@ -3666,6 +3700,16 @@ export class GitHubApi {
3666
3700
  }
3667
3701
  }
3668
3702
 
3703
+ /**
3704
+ * The whole list rather than a page: this read has no cursor to hand back (its `IntegrationResult<T[]>` return
3705
+ * has no paging channel at all), so it drains up to {@link maxMyPullRequestSearchPages} pages itself.
3706
+ *
3707
+ * Draining instead of taking one page is what lets the full projection page at the reduced size everywhere
3708
+ * (see the `limit` in {@link searchMyPullRequestsPage}): a single page would otherwise have to ask for 100
3709
+ * nodes of the selection GitHub rejects at that size, purely because this caller cannot resume. Ordering is
3710
+ * `sort:updated`, so the pages compose into a defined window rather than a shifting relevance ranking, and the
3711
+ * drain stops as soon as GitHub reports no next page.
3712
+ */
3669
3713
  @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3670
3714
  async searchMyPullRequests(
3671
3715
  provider: Provider,
@@ -3681,7 +3725,33 @@ export class GitHubApi {
3681
3725
  },
3682
3726
  cancellation?: AbortSignal,
3683
3727
  ): Promise<PullRequest[]> {
3684
- return (await this.searchMyPullRequestsPage(provider, token, options, cancellation)).values;
3728
+ const values: PullRequest[] = [];
3729
+ // The searches behind one page can overlap (a PR can be authored by and review-requested from the same
3730
+ // user), and a later page can repeat a row an earlier one already served if the PR was updated mid-drain.
3731
+ const seen = new Set<string>();
3732
+ let cursor: string | undefined;
3733
+
3734
+ for (let page = 0; page < maxMyPullRequestSearchPages; page++) {
3735
+ const result = await this.searchMyPullRequestsPage(
3736
+ provider,
3737
+ token,
3738
+ { ...options, cursor: cursor },
3739
+ cancellation,
3740
+ );
3741
+ for (const pr of result.values) {
3742
+ if (seen.has(pr.url)) continue;
3743
+
3744
+ seen.add(pr.url);
3745
+ values.push(pr);
3746
+ }
3747
+
3748
+ // A missing or repeated cursor with `hasMore` would loop on the same page forever.
3749
+ if (!result.hasMore || result.cursor == null || result.cursor === cursor) break;
3750
+
3751
+ cursor = result.cursor;
3752
+ }
3753
+
3754
+ return values;
3685
3755
  }
3686
3756
 
3687
3757
  /**
@@ -3948,6 +4018,85 @@ export class GitHubApi {
3948
4018
  }
3949
4019
  }
3950
4020
 
4021
+ /**
4022
+ * The PR twin of {@link countIssues}: counts pull requests for several scopes in ONE request via aliased
4023
+ * `search` fields selecting only `issueCount` with `first: 0`. Same positional/undefined contract as the issue
4024
+ * count.
4025
+ *
4026
+ * The one difference is states. {@link toGitHubPullRequestSearchFacets} fans a scope's states out into one
4027
+ * `search` each, so this reports the count the SAME way {@link searchPullRequestsPage} reports its total — the
4028
+ * LARGEST facet's `issueCount`, not their sum: the result ceiling applies per search, so the max is what
4029
+ * `exceedsProviderLimit` compares against, and summing would claim a total the read never surfaces. Relationships
4030
+ * are OR-ed and can't be a single count; the facade refuses a multi-relationship scope, so at most one is here.
4031
+ */
4032
+ @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
4033
+ async countPullRequests(
4034
+ provider: Provider,
4035
+ token: GitHubTokenInfo,
4036
+ scopes: readonly { repos?: string[]; org?: string; criteria?: PullRequestSearchCriteria }[],
4037
+ options?: { baseUrl?: string },
4038
+ cancellation?: AbortSignal,
4039
+ ): Promise<(number | undefined)[]> {
4040
+ const scope = getScopedLogger();
4041
+ if (scopes.length === 0) return [];
4042
+
4043
+ // Each scope expands to one query string per state facet (all sharing its single relationship, which the
4044
+ // facade guarantees). A scope's count is the MAX across its facets — mirroring searchPullRequestsPage's
4045
+ // totalCount — so the aliases stay grouped by scope and are reduced after the response.
4046
+ const scopeQueries = scopes.map(s => {
4047
+ const scopeQualifiers = toGitHubIssueSearchScopeQualifiers(s.org, s.repos);
4048
+ return toGitHubPullRequestSearchFacets(s.criteria).map(f =>
4049
+ [...scopeQualifiers, ...f.qualifiers].join(' '),
4050
+ );
4051
+ });
4052
+
4053
+ // Aliases are positional and generated (`s${scope}f${facet}`): a caller's key is arbitrary text and would
4054
+ // break the document, so results are reduced back to one count per scope by index.
4055
+ const aliased = scopeQueries.flatMap((queries, si) =>
4056
+ queries.map((query, fi) => ({ alias: `s${si}f${fi}`, query: query })),
4057
+ );
4058
+ const params = aliased.map(a => `$${a.alias}: String!`).join('\n\t\t\t\t');
4059
+ // `first: 0` is what makes this cheap — `issueCount` alone, no nodes over the wire.
4060
+ const fields = aliased
4061
+ .map(a => `${a.alias}: search(query: $${a.alias}, type: ISSUE, first: 0) { issueCount }`)
4062
+ .join('\n\t\t\t\t');
4063
+ const query = `query countPullRequests(
4064
+ ${params}
4065
+ ) {
4066
+ ${fields}
4067
+ }`;
4068
+
4069
+ const variables: Record<string, unknown> = { baseUrl: options?.baseUrl };
4070
+ for (const a of aliased) {
4071
+ variables[a.alias] = a.query;
4072
+ }
4073
+
4074
+ try {
4075
+ const rsp = await this.graphql<Record<string, { issueCount?: number } | undefined>>(
4076
+ provider,
4077
+ token,
4078
+ query,
4079
+ variables,
4080
+ scope,
4081
+ cancellation,
4082
+ );
4083
+ if (rsp == null) return scopes.map(() => undefined);
4084
+
4085
+ return scopeQueries.map((queries, si) => {
4086
+ let max: number | undefined;
4087
+ for (let fi = 0; fi < queries.length; fi++) {
4088
+ const count = rsp[`s${si}f${fi}`]?.issueCount;
4089
+ if (count != null) {
4090
+ max = max == null ? count : Math.max(max, count);
4091
+ }
4092
+ }
4093
+ return max;
4094
+ });
4095
+ } catch (ex) {
4096
+ throw this.handleException(ex, provider, scope);
4097
+ }
4098
+ }
4099
+
3951
4100
  /**
3952
4101
  * The aliased-search engine behind every GitHub issue search: one GraphQL request carrying N independently
3953
4102
  * cursored `search` fields, `@include`-gated so an exhausted or unrequested one costs nothing.
@@ -4234,8 +4383,11 @@ export class GitHubApi {
4234
4383
  * every active relationship × state facet, so one HTTP request serves one page even when the logical search is
4235
4384
  * a union. The cursor preserves each facet's continuation plus the positional page.
4236
4385
  *
4237
- * Ordering is always most-recently-updated-first, and user text is sanitized before it reaches the provider
4238
- * query. See {@link toGitHubPullRequestSearchFacets}.
4386
+ * Ordering is `criteria.sort`, defaulting to most-recently-updated-first the order this read served before it
4387
+ * was expressible. It is requested of the provider AND re-applied to the merged page, because the page is a
4388
+ * union of facets and no per-facet server order describes it. The sort is part of the cursor fingerprint, so
4389
+ * changing it invalidates a threaded cursor exactly as changing the text or the scope does. User text is
4390
+ * sanitized before it reaches the provider query. See {@link toGitHubPullRequestSearchFacets}.
4239
4391
  */
4240
4392
  @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
4241
4393
  async searchPullRequestsPage(
@@ -4395,7 +4547,15 @@ export class GitHubApi {
4395
4547
  }
4396
4548
  }
4397
4549
  }
4398
- pullRequests.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime());
4550
+ // The PR path re-sorts the merged page rather than trusting the per-facet server order — GitHub's
4551
+ // server-side PR sort has been unreliable (the per-branch path re-sorts for the same reason), and the
4552
+ // union of several facets is unordered regardless. The key was validated against
4553
+ // `githubPullRequestSearchCapabilities.sorts` upstream, so its comparator is always defined for a PR
4554
+ // shape; the guard only guards the unreachable case rather than inventing an order for it.
4555
+ const comparator = getPullRequestComparator(options?.criteria?.sort ?? defaultPullRequestSort);
4556
+ if (comparator != null) {
4557
+ pullRequests.sort(comparator);
4558
+ }
4399
4559
  const values = [
4400
4560
  ...uniqueBy(
4401
4561
  pullRequests,
@@ -1,6 +1,10 @@
1
- import type { PullRequestSearchCriteria, PullRequestStateFilter } from '../../../git/models/pullRequest.js';
2
- import { PullRequestFilter } from '../../../git/models/pullRequest.js';
3
- import { sanitizeGitHubSearchText } from './issueSearchQuery.js';
1
+ import type {
2
+ PullRequestSearchCriteria,
3
+ PullRequestSorting,
4
+ PullRequestStateFilter,
5
+ } from '../../../git/models/pullRequest.js';
6
+ import { defaultPullRequestSort, PullRequestFilter } from '../../../git/models/pullRequest.js';
7
+ import { sanitizeGitHubQualifierValue, sanitizeGitHubSearchText } from './issueSearchQuery.js';
4
8
 
5
9
  export type GitHubPullRequestSearchFacet = {
6
10
  /** Stable GraphQL alias and composite-cursor key. */
@@ -8,10 +12,20 @@ export type GitHubPullRequestSearchFacet = {
8
12
  qualifiers: string[];
9
13
  };
10
14
 
11
- const relationshipQualifier: Record<PullRequestFilter, string> = {
15
+ /**
16
+ * The GitHub search qualifier each relationship translates to. Exhaustive over `PullRequestFilter` so a new
17
+ * member can't be silently unmapped.
18
+ *
19
+ * Exported because the account-wide read builds its own facets from the same vocabulary (see
20
+ * `GitHubIntegrationBase.getProviderMyPullRequestsForUser`). Kept as ONE declaration rather than a copy per
21
+ * caller: the exhaustive `Record` makes a missing key a compile error, but nothing would catch two copies
22
+ * disagreeing on a qualifier STRING, and a wrong qualifier is a read that quietly answers a different question.
23
+ */
24
+ export const gitHubPullRequestRelationshipQualifiers: Record<PullRequestFilter, string> = {
12
25
  [PullRequestFilter.Author]: 'author:@me',
13
26
  [PullRequestFilter.Assignee]: 'assignee:@me',
14
27
  [PullRequestFilter.ReviewRequested]: 'review-requested:@me',
28
+ [PullRequestFilter.Reviewed]: 'reviewed-by:@me',
15
29
  [PullRequestFilter.Mention]: 'mentions:@me',
16
30
  };
17
31
 
@@ -19,6 +33,7 @@ const relationshipAlias: Record<PullRequestFilter, string> = {
19
33
  [PullRequestFilter.Author]: 'author',
20
34
  [PullRequestFilter.Assignee]: 'assignee',
21
35
  [PullRequestFilter.ReviewRequested]: 'reviewRequested',
36
+ [PullRequestFilter.Reviewed]: 'reviewed',
22
37
  [PullRequestFilter.Mention]: 'mention',
23
38
  };
24
39
 
@@ -29,6 +44,30 @@ const stateAlias: Record<PullRequestStateFilter, string> = {
29
44
  all: 'All',
30
45
  };
31
46
 
47
+ /**
48
+ * How each orderable key becomes a GitHub `sort:` qualifier.
49
+ *
50
+ * A literal `Record` over the union rather than a derived transform, for the same reason the relationship tables
51
+ * are: adding a sort field without deciding its qualifier fails the build instead of quietly emitting a search
52
+ * with no ordering constraint — the one failure mode the ordering contract exists to prevent, and the one a
53
+ * caller cannot detect from the result.
54
+ *
55
+ * `updated:desc` maps to the bare `sort:updated`, not `sort:updated-desc`. The two are the same query to GitHub,
56
+ * but the bare form is what this read has always emitted, so keeping it makes the default byte-identical to
57
+ * today's query — matching `toGitHubIssueSortQualifier`. Do not "normalize" it.
58
+ */
59
+ export const gitHubPullRequestSortQualifiers: Partial<Record<PullRequestSorting, string>> = {
60
+ 'created:asc': 'sort:created-asc',
61
+ 'created:desc': 'sort:created-desc',
62
+ 'updated:asc': 'sort:updated-asc',
63
+ 'updated:desc': 'sort:updated',
64
+ };
65
+
66
+ /** The `sort:` qualifier for a key, or `undefined` when there is no key or GitHub can't express it. */
67
+ export function toGitHubPullRequestSortQualifier(sort: PullRequestSorting | undefined): string | undefined {
68
+ return sort != null ? gitHubPullRequestSortQualifiers[sort] : undefined;
69
+ }
70
+
32
71
  function stateQualifiers(state: PullRequestStateFilter): string[] {
33
72
  switch (state) {
34
73
  case 'closed':
@@ -62,18 +101,53 @@ export function toGitHubPullRequestSearchFacets(
62
101
  : ['open'];
63
102
  const states: PullRequestStateFilter[] = requestedStates.includes('all') ? ['all'] : requestedStates;
64
103
  const text = criteria?.text != null ? sanitizeGitHubSearchText(criteria.text) : '';
104
+ // Ordering is part of the contract, not an option: without an explicit `sort:` GitHub answers in relevance
105
+ // order, so which rows land inside the result ceiling would shift with its ranking even when nothing changed
106
+ // upstream. A key GitHub can't express is REFUSED here, not approximated with a fallback order — a silent
107
+ // `sort:updated` substitute would emit one order in the query while the merged-page comparator applied another
108
+ // (or none for an unknown field), which is exactly the "the N most recent are what you get" promise the ceiling
109
+ // contract cannot keep under a guessed order. Unreachable through the facade — every key is validated against
110
+ // `githubPullRequestSearchCapabilities.sorts` first — so this guards the direct callers of this exported builder
111
+ // and of `GitHubApi.searchPullRequestsPage`.
112
+ //
113
+ // Deliberately STRICTER than the issue path, which drops an inexpressible qualifier and searches on
114
+ // (`toGitHubIssueSearchQualifiers` pushes `toGitHubIssueSortQualifier`'s result only when it is non-null). The
115
+ // two reads differ in what that costs: an issue search is served by whichever facets the provider ordered, while
116
+ // this one hands its merged page to `getPullRequestComparator`, so a dropped qualifier here means the query and
117
+ // the comparator disagree about the order the ceiling was applied under. Refusing is the only answer that
118
+ // cannot lie about which rows were reachable.
119
+ const sort = criteria?.sort ?? defaultPullRequestSort;
120
+ const sortQualifier = toGitHubPullRequestSortQualifier(sort);
121
+ if (sortQualifier == null) {
122
+ throw new Error(`GitHub cannot order a pull request search by '${sort}'`);
123
+ }
124
+
125
+ // Facet-independent, unlike the relationship/state qualifiers below — build once and share.
126
+ const dateQualifiers: string[] = [];
127
+ const pushDate = (value: string | undefined, toQualifier: (sanitized: string) => string): void => {
128
+ if (value == null) return;
129
+
130
+ const sanitized = sanitizeGitHubQualifierValue(value);
131
+ if (sanitized.length > 0) {
132
+ dateQualifiers.push(toQualifier(sanitized));
133
+ }
134
+ };
135
+ pushDate(criteria?.updatedAfter, v => `updated:>=${v}`);
136
+ pushDate(criteria?.createdAfter, v => `created:>=${v}`);
65
137
 
66
138
  return relationships.flatMap(relationship =>
67
139
  states.map(state => ({
68
140
  alias: `${relationship != null ? relationshipAlias[relationship] : 'scope'}${stateAlias[state]}`,
69
141
  qualifiers: [
70
142
  'is:pr',
71
- ...(relationship != null ? [relationshipQualifier[relationship]] : []),
143
+ ...(relationship != null ? [gitHubPullRequestRelationshipQualifiers[relationship]] : []),
72
144
  ...stateQualifiers(state),
145
+ // `!= null`, not truthy: `draft:false` narrows to ready-for-review PRs just as `draft:true` narrows to drafts.
146
+ ...(criteria?.draft != null ? [`draft:${criteria.draft}`] : []),
73
147
  ...(criteria?.includeArchived === true ? [] : ['archived:false']),
74
148
  ...(text.length > 0 ? [text] : []),
75
- // Contract, not an option: a capped result is the N most recently updated only under this order.
76
- 'sort:updated',
149
+ ...dateQualifiers,
150
+ sortQualifier,
77
151
  ],
78
152
  })),
79
153
  );
@@ -194,6 +194,13 @@ export type GitHubPullRequestMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNK
194
194
  export type GitHubPullRequestStatusCheckRollupState = 'SUCCESS' | 'FAILURE' | 'PENDING' | 'EXPECTED' | 'ERROR';
195
195
  export type GitHubPullRequestReviewState = 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING';
196
196
 
197
+ type GitHubPullRequestReview = {
198
+ id: string;
199
+ author: GitHubMember | null;
200
+ state: GitHubPullRequestReviewState;
201
+ commit?: { oid: string } | null;
202
+ };
203
+
197
204
  export interface GitHubPullRequest extends GitHubPullRequestLite {
198
205
  additions: number;
199
206
  assignees: {
@@ -204,11 +211,14 @@ export interface GitHubPullRequest extends GitHubPullRequestLite {
204
211
  mergeable: GitHubPullRequestMergeableState;
205
212
  reviewDecision: GitHubPullRequestReviewDecision;
206
213
  latestReviews: {
207
- nodes: {
208
- author: GitHubMember | null;
209
- state: GitHubPullRequestReviewState;
210
- }[];
214
+ nodes: GitHubPullRequestReview[];
211
215
  };
216
+ /**
217
+ * The current user's own latest review. `latestReviews` is capped, so on a heavily-reviewed pull request
218
+ * the viewer's own review can fall outside that window — which is exactly the row a "needs my review"
219
+ * surface is asking about.
220
+ */
221
+ viewerLatestReview: GitHubPullRequestReview | null;
212
222
  reviewRequests: {
213
223
  nodes: {
214
224
  asCodeOwner: boolean;
@@ -401,6 +411,19 @@ export function fromGitHubPullRequestStatusCheckRollupState(
401
411
  }
402
412
 
403
413
  export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider): PullRequest {
414
+ // `latestReviews` is capped, so keep the viewer's own review even when it falls outside that window, deduped
415
+ // by review id since the two selections overlap. Unsubmitted drafts are dropped from the union rather than
416
+ // from the viewer's side alone: `PENDING` is in GitHub's review-state enum on both selections, and the field
417
+ // this feeds is documented as reviews already SUBMITTED — a draft there tells a "needs my review" consumer
418
+ // the review is done, and a reviewer with a submitted review plus a draft would get two conflicting rows.
419
+ const viewerLatestReview = pr.viewerLatestReview;
420
+ const latestReviews = [
421
+ ...pr.latestReviews.nodes,
422
+ ...(viewerLatestReview != null && !pr.latestReviews.nodes.some(r => r.id === viewerLatestReview.id)
423
+ ? [viewerLatestReview]
424
+ : []),
425
+ ].filter(review => review.state !== 'PENDING');
426
+
404
427
  return new PullRequest(
405
428
  provider,
406
429
  fromGitHubMemberOrGhost(pr.author),
@@ -467,9 +490,10 @@ export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider)
467
490
  : undefined,
468
491
  )
469
492
  .filter(<T>(r?: T): r is T => Boolean(r)),
470
- pr.latestReviews.nodes.map(r => ({
493
+ latestReviews.map(r => ({
471
494
  reviewer: fromGitHubMemberOrGhost(r.author),
472
495
  state: fromGitHubPullRequestReviewState(r.state),
496
+ commitOid: r.commit?.oid,
473
497
  })),
474
498
  pr.assignees.nodes.map(r => ({
475
499
  id: r.login,
@@ -77,8 +77,13 @@ import { providersMetadata } from './providers/models.js';
77
77
  import type { ProvidersApi } from './providers/providersApi.js';
78
78
  import { broadenIssues } from './reads/broaden.js';
79
79
  import type { RepositoryResolutionContext } from './reads/context.js';
80
- import type { IssueCountResult, IssueCountScope } from './reads/counts.js';
81
- import { countIssues } from './reads/counts.js';
80
+ import type {
81
+ IssueCountResult,
82
+ IssueCountScope,
83
+ PullRequestCountResult,
84
+ PullRequestCountScope,
85
+ } from './reads/counts.js';
86
+ import { countIssues, countPullRequests } from './reads/counts.js';
82
87
  import type { SupportedFilters } from './reads/filters.js';
83
88
  import { getSupportedFilters } from './reads/filters.js';
84
89
  import { listOrgs, listProjects, listRepos } from './reads/hierarchy.js';
@@ -1147,6 +1152,24 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1147
1152
  return countIssues(this, options);
1148
1153
  }
1149
1154
 
1155
+ /**
1156
+ * How many pull requests match each scope, without fetching any — the PR twin of {@link countIssues}. See
1157
+ * {@link IntegrationManager.countPullRequests} for the cost model, the per-scope isolation rules, and why
1158
+ * `count: undefined` must not be rendered as zero.
1159
+ */
1160
+ async countPullRequests(options: {
1161
+ providerId: IntegrationIds;
1162
+ scopes: readonly PullRequestCountScope[];
1163
+ connectionId?: string;
1164
+ /**
1165
+ * Explicit self-managed host domain. Used only when the requested connection has no configured domain;
1166
+ * it must come from the trusted authentication configuration, not repository or remote data.
1167
+ */
1168
+ domain?: string;
1169
+ }): Promise<ProviderResult<PullRequestCountResult>> {
1170
+ return countPullRequests(this, options);
1171
+ }
1172
+
1150
1173
  /**
1151
1174
  * Reads the user's issues from an issue-tracker provider (Jira/Linear/Trello), whose issues live under
1152
1175
  * resource → project (not repos), so they can't go through {@link listIssuesPage} (git-host, repo-scoped).
@@ -9,7 +9,12 @@ import type { ConfiguredIntegrationsChangeEvent } from './authentication/configu
9
9
  import type { ConfiguredIntegrationDescriptor } from './authentication/models.js';
10
10
  import type { IntegrationIds } from './constants.js';
11
11
  import type { IssueFilter, IssueSorting, PullRequestFilter } from './providerFilters.js';
12
- import type { IssueCountResult, IssueCountScope } from './reads/counts.js';
12
+ import type {
13
+ IssueCountResult,
14
+ IssueCountScope,
15
+ PullRequestCountResult,
16
+ PullRequestCountScope,
17
+ } from './reads/counts.js';
13
18
  import type { SupportedFilters } from './reads/filters.js';
14
19
  import type {
15
20
  ConnectionStateChangeEvent,
@@ -156,6 +161,22 @@ type PullRequestSweepCommonOptions = {
156
161
  * This extends the provider-native result; it is not a narrowing filter.
157
162
  */
158
163
  includeReviewRequested?: boolean;
164
+ /**
165
+ * Account-wide only: fetch the full PR projection (review decision, review requests, latest reviews)
166
+ * instead of the default summary/lite one. Opt-in because it enlarges every account-wide page; callers
167
+ * that classify by review state (e.g. a "needs my review" surface) set it, others keep the lean read.
168
+ *
169
+ * GitHub/GHE only today — no other provider's account-wide read has a projection switch, so they return
170
+ * their native shape regardless. A cross-provider sweep therefore gets reviews from GitHub targets and
171
+ * whatever the others natively carry; it is a breadth request, not a guarantee, so it does not refuse
172
+ * (unlike `filters`, where dropping a member would change the requested set).
173
+ *
174
+ * Projection only: it cannot change which pull requests come back. Every GitHub account-wide read goes through
175
+ * the same search whether or not it is set, so the Launchpad ignored-repository and organization qualifiers
176
+ * (and the per-facet result ceiling) apply the same either way. What it costs is the reduced page size, and a
177
+ * document whose weight scales with the number of relationship × state facets in flight.
178
+ */
179
+ includeReviews?: boolean;
159
180
  forceSync?: boolean;
160
181
  maxPages?: number;
161
182
  /**
@@ -355,10 +376,17 @@ export interface IntegrationManager {
355
376
  * GitHub's mismatched `involves:@me`. Omit relationships only with a repository/organization scope to search
356
377
  * every PR there.
357
378
  *
358
- * Results are always ordered most-recently-updated-first. If the provider's result ceiling is reached, the
359
- * request still succeeds and carries a warning omission with `totalCount`, `limit`, and `recovery: 'none'`.
360
- * `totalCount` is the largest provider-reported pre-ceiling facet count, not the reachable or returned row
361
- * count; this mirrors the per-search ceiling's own unit.
379
+ * **Ordering is `criteria.sort`**, most-recently-updated-first when omitted. It is validated like every other
380
+ * criterion a key not in `getSupportedFilters().pullRequestSearch.sorts` refuses the whole read rather than
381
+ * falling back, because at a bounded result window another order reaches another subset. The page is a union of
382
+ * the provider's relationship × state facets, so it is re-ordered as a whole rather than served as concatenated
383
+ * per-facet runs. Changing the sort invalidates a threaded cursor (it is part of the cursor's fingerprint,
384
+ * alongside the text and the scope); drop the cursor when you change the order.
385
+ *
386
+ * If the provider's result ceiling is reached, the request still succeeds and carries a warning omission with
387
+ * `totalCount`, `limit`, `sort`, and `recovery: 'none'`. `totalCount` is the largest provider-reported
388
+ * pre-ceiling facet count, not the reachable or returned row count; this mirrors the per-search ceiling's own
389
+ * unit.
362
390
  *
363
391
  * Check `getSupportedFilters().pullRequestSearch` before calling. A provider that reports no relationships
364
392
  * refuses the read rather than returning a page that never honored the criteria or scope.
@@ -518,6 +546,23 @@ export interface IntegrationManager {
518
546
  /** Self-managed host domain fallback; see {@link ProviderSweepTarget.domain}. */
519
547
  domain?: string;
520
548
  }): Promise<ProviderResult<IssueCountResult>>;
549
+ /**
550
+ * How many pull requests match each scope, fetching none of them — the PR twin of {@link countIssues}, behind a
551
+ * "this will fetch ~N pull requests" preview and a live count next to an unapplied filter. Same cost model,
552
+ * per-scope isolation, `key`-echo, and `count: undefined` ≠ zero rule as the issue count; GitHub/GHE only.
553
+ *
554
+ * The one PR-specific difference: a scope's `states` are counted as independent searches, so the reported count
555
+ * is the LARGEST of them (the same total {@link searchPullRequestsPage} surfaces), not their sum. Several states
556
+ * in one scope are therefore fine; only several relationships are refused — one relationship per scope, see
557
+ * {@link PullRequestCountScope}.
558
+ */
559
+ countPullRequests(options: {
560
+ providerId: IntegrationIds;
561
+ scopes: readonly PullRequestCountScope[];
562
+ connectionId?: string;
563
+ /** Self-managed host domain fallback; see {@link ProviderSweepTarget.domain}. */
564
+ domain?: string;
565
+ }): Promise<ProviderResult<PullRequestCountResult>>;
521
566
  /** Issue trackers are cloud-only, so this read takes no `domain`. */
522
567
  listIssueTrackerIssuesPage(options: {
523
568
  providerId: IntegrationIds;