@gitkraken/core-gitlens 0.5.105 → 0.5.107

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 (76) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/dist/git/models/pullRequest.d.ts +47 -0
  3. package/dist/git/models/pullRequest.d.ts.map +1 -1
  4. package/dist/git/models/pullRequest.js +8 -0
  5. package/dist/git/models/pullRequest.js.map +1 -1
  6. package/dist/plus/git-github/api/github.d.ts +42 -1
  7. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  8. package/dist/plus/git-github/api/github.js +194 -1
  9. package/dist/plus/git-github/api/github.js.map +1 -1
  10. package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts +16 -0
  11. package/dist/plus/git-github/api/pullRequestSearchQuery.d.ts.map +1 -0
  12. package/dist/plus/git-github/api/pullRequestSearchQuery.js +64 -0
  13. package/dist/plus/git-github/api/pullRequestSearchQuery.js.map +1 -0
  14. package/dist/plus/integrations/index.d.ts +1 -0
  15. package/dist/plus/integrations/index.d.ts.map +1 -1
  16. package/dist/plus/integrations/integrationService.d.ts +16 -2
  17. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  18. package/dist/plus/integrations/integrationService.js +21 -1
  19. package/dist/plus/integrations/integrationService.js.map +1 -1
  20. package/dist/plus/integrations/manager.d.ts +46 -4
  21. package/dist/plus/integrations/manager.d.ts.map +1 -1
  22. package/dist/plus/integrations/models/gitHostIntegration.d.ts +22 -2
  23. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  24. package/dist/plus/integrations/models/gitHostIntegration.js +21 -0
  25. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  26. package/dist/plus/integrations/models/integration.d.ts +2 -1
  27. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  28. package/dist/plus/integrations/models/integration.js +7 -1
  29. package/dist/plus/integrations/models/integration.js.map +1 -1
  30. package/dist/plus/integrations/models/pullRequestReads.d.ts +18 -0
  31. package/dist/plus/integrations/models/pullRequestReads.d.ts.map +1 -0
  32. package/dist/plus/integrations/models/pullRequestReads.js +2 -0
  33. package/dist/plus/integrations/models/pullRequestReads.js.map +1 -0
  34. package/dist/plus/integrations/providerFilters.d.ts +7 -6
  35. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  36. package/dist/plus/integrations/providerFilters.js +1 -7
  37. package/dist/plus/integrations/providerFilters.js.map +1 -1
  38. package/dist/plus/integrations/providers/github.d.ts +14 -2
  39. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  40. package/dist/plus/integrations/providers/github.js +15 -0
  41. package/dist/plus/integrations/providers/github.js.map +1 -1
  42. package/dist/plus/integrations/providers/models.d.ts +12 -2
  43. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  44. package/dist/plus/integrations/providers/models.js +18 -0
  45. package/dist/plus/integrations/providers/models.js.map +1 -1
  46. package/dist/plus/integrations/reads/context.d.ts +1 -1
  47. package/dist/plus/integrations/reads/filters.d.ts +34 -1
  48. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  49. package/dist/plus/integrations/reads/filters.js +73 -0
  50. package/dist/plus/integrations/reads/filters.js.map +1 -1
  51. package/dist/plus/integrations/reads/searchPullRequests.d.ts +26 -0
  52. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -0
  53. package/dist/plus/integrations/reads/searchPullRequests.js +108 -0
  54. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -0
  55. package/dist/plus/integrations/reads/warnings.d.ts +6 -2
  56. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  57. package/dist/plus/integrations/reads/warnings.js +40 -0
  58. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  59. package/docs/integrations.md +53 -4
  60. package/package.json +1 -1
  61. package/src/git/models/pullRequest.ts +50 -0
  62. package/src/plus/git-github/api/github.ts +272 -2
  63. package/src/plus/git-github/api/pullRequestSearchQuery.ts +80 -0
  64. package/src/plus/integrations/index.ts +2 -0
  65. package/src/plus/integrations/integrationService.ts +48 -5
  66. package/src/plus/integrations/manager.ts +55 -4
  67. package/src/plus/integrations/models/gitHostIntegration.ts +56 -2
  68. package/src/plus/integrations/models/integration.ts +9 -1
  69. package/src/plus/integrations/models/pullRequestReads.ts +18 -0
  70. package/src/plus/integrations/providerFilters.ts +8 -6
  71. package/src/plus/integrations/providers/github.ts +37 -1
  72. package/src/plus/integrations/providers/models.ts +37 -2
  73. package/src/plus/integrations/reads/context.ts +1 -1
  74. package/src/plus/integrations/reads/filters.ts +105 -0
  75. package/src/plus/integrations/reads/searchPullRequests.ts +210 -0
  76. package/src/plus/integrations/reads/warnings.ts +73 -3
@@ -13,7 +13,13 @@ import type { Account, UnidentifiedAuthor } from '../../../git/models/author.js'
13
13
  import type { DefaultBranch } from '../../../git/models/defaultBranch.js';
14
14
  import type { Issue, IssueSearchCriteria, IssueShape } from '../../../git/models/issue.js';
15
15
  import type { IssueOrPullRequest } from '../../../git/models/issueOrPullRequest.js';
16
- import type { PullRequest, PullRequestState, PullRequestStateFilter } from '../../../git/models/pullRequest.js';
16
+ import type {
17
+ PullRequest,
18
+ PullRequestSearchCriteria,
19
+ PullRequestShape,
20
+ PullRequestState,
21
+ PullRequestStateFilter,
22
+ } from '../../../git/models/pullRequest.js';
17
23
  import { PullRequestMergeMethod } from '../../../git/models/pullRequest.js';
18
24
  import type { Provider } from '../../../git/models/remoteProvider.js';
19
25
  import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata.js';
@@ -72,6 +78,7 @@ import {
72
78
  toGitHubIssueSearchQualifiers,
73
79
  toGitHubIssueSearchScopeQualifiers,
74
80
  } from './issueSearchQuery.js';
81
+ import { toGitHubPullRequestSearchFacets } from './pullRequestSearchQuery.js';
75
82
  import type { GitHubTokenInfo } from './token.js';
76
83
 
77
84
  const emptyPagedResult: PagedResult<any> = Object.freeze({ values: [] });
@@ -89,6 +96,13 @@ const requestRetryMaxDelay = 2000; // ms
89
96
  /** How many email->login user searches to alias into a single GraphQL request (keeps query cost within limits). */
90
97
  const accountResolveBatchSize = 25;
91
98
 
99
+ // Pull-request search selects the full PR fragment (reviews, requests, refs, commits, etc.),
100
+ // which makes GitHub reject broad 100-node searches with `Resource limits for this query
101
+ // exceeded` on large repositories. Thirty keeps the default within that GraphQL cost budget
102
+ // while callers that know their scope is cheap can still opt into the supported 100-node max.
103
+ const defaultPullRequestSearchPageSize = 30;
104
+ const maxPullRequestSearchPageSize = 100;
105
+
92
106
  function isRetryableTransientError(ex: unknown): ex is RequestError {
93
107
  // An aborted request is rethrown as the original `AbortError` (not a `RequestError`), so it is
94
108
  // excluded here. octokit maps a fetch/network failure to a `RequestError` with status 500 and
@@ -164,6 +178,22 @@ export interface AliasedIssueSearchResult {
164
178
  totalCount?: number;
165
179
  }
166
180
 
181
+ /** One page of the filtered pull-request search. */
182
+ export interface PullRequestSearchResult {
183
+ values: PullRequestShape[];
184
+ /** Opaque cursor carrying each active facet continuation plus the positional page. */
185
+ cursor?: string;
186
+ hasMore: boolean;
187
+ page: number;
188
+ /** True at GitHub's search ceiling or when GitHub advertises a page without a usable cursor. */
189
+ truncated: boolean;
190
+ /**
191
+ * The largest pre-ceiling `issueCount` any relationship × state facet reported, which is what
192
+ * {@link githubSearchResultLimit} applies to. Never the number of rows reachable after that ceiling.
193
+ */
194
+ totalCount?: number;
195
+ }
196
+
167
197
  // Matches a GraphQL document whose operation is definitely a read query — the `query` keyword as
168
198
  // the first significant token, after any leading whitespace or `#` comment lines. We retry only
169
199
  // when this matches; a mutation (even one prefixed by a comment), a subscription, or anything we
@@ -3480,6 +3510,15 @@ export class GitHubApi {
3480
3510
  return `https://avatars.githubusercontent.com/u/e?email=${encodeURIComponent(email)}&s=${avatarSize}`;
3481
3511
  }
3482
3512
 
3513
+ /**
3514
+ * One page of the current user's pull requests, filtered by state and optionally by an explicit
3515
+ * relationship qualifier. Backs the PR sweeps, which drain it page by page.
3516
+ *
3517
+ * Ordering is part of the contract, not an option: always `sort:updated` (most recently updated first),
3518
+ * matching {@link searchPullRequestsPage}. A caller that stops before `hasMore` clears — every sweep with a
3519
+ * page budget — therefore retains a well-defined recency window instead of an arbitrary slice of GitHub's
3520
+ * relevance ranking.
3521
+ */
3483
3522
  @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
3484
3523
  async searchMyPullRequestsPage(
3485
3524
  provider: Provider,
@@ -3578,12 +3617,20 @@ export class GitHubApi {
3578
3617
  options?.includeDefaultInvolvement === false
3579
3618
  ? 'is:pr archived:false'
3580
3619
  : 'is:pr involves:@me archived:false';
3620
+ // Ordering is part of the contract, not an option — same as `searchPullRequestsPage`. Without it
3621
+ // GitHub answers in `best-match` (relevance) order, so any result set the caller stops short of
3622
+ // draining is an arbitrary sample rather than "the N most recent": which rows land inside a page
3623
+ // budget can then shift with GitHub's ranking even when nothing changed upstream. Consumers that
3624
+ // cap the walk depend on this to make their window deterministic and time-bounded.
3581
3625
  const rsp = await this.graphql<SearchResult>(
3582
3626
  provider,
3583
3627
  token,
3584
3628
  query,
3585
3629
  {
3586
- search: [stateQualifier, relationshipQualifier, search].filter(Boolean).join(' ').trim(),
3630
+ search: [stateQualifier, relationshipQualifier, search, 'sort:updated']
3631
+ .filter(Boolean)
3632
+ .join(' ')
3633
+ .trim(),
3587
3634
  cursor: options?.cursor,
3588
3635
  baseUrl: options?.baseUrl,
3589
3636
  avatarSize: options?.avatarSize,
@@ -4022,6 +4069,229 @@ export class GitHubApi {
4022
4069
  }
4023
4070
  }
4024
4071
 
4072
+ /**
4073
+ * Searches pull requests over a repository/org or current-user relationship scope. One GraphQL document carries
4074
+ * every active relationship × state facet, so one HTTP request serves one page even when the logical search is
4075
+ * a union. The cursor preserves each facet's continuation plus the positional page.
4076
+ *
4077
+ * Ordering is always most-recently-updated-first, and user text is sanitized before it reaches the provider
4078
+ * query. See {@link toGitHubPullRequestSearchFacets}.
4079
+ */
4080
+ @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
4081
+ async searchPullRequestsPage(
4082
+ provider: Provider,
4083
+ token: GitHubTokenInfo,
4084
+ options?: {
4085
+ repos?: string[];
4086
+ org?: string;
4087
+ criteria?: PullRequestSearchCriteria;
4088
+ baseUrl?: string;
4089
+ avatarSize?: number;
4090
+ cursor?: string;
4091
+ pageSize?: number;
4092
+ },
4093
+ cancellation?: AbortSignal,
4094
+ ): Promise<PullRequestSearchResult | undefined> {
4095
+ const scope = getScopedLogger();
4096
+
4097
+ const pageSize = Math.min(
4098
+ maxPullRequestSearchPageSize,
4099
+ Math.max(1, Math.trunc(options?.pageSize ?? defaultPullRequestSearchPageSize)),
4100
+ );
4101
+ const facets = toGitHubPullRequestSearchFacets(options?.criteria);
4102
+ const facetAliases = facets.map(f => f.alias).sort();
4103
+ const scopeQualifiers = toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos);
4104
+ const facetSearches = new Map(
4105
+ facets.map(f => [f.alias, [...scopeQualifiers, ...f.qualifiers].join(' ')] as const),
4106
+ );
4107
+ // Bind a cursor to every qualifier without publishing the user text/scope inside the opaque cursor. FNV-1a
4108
+ // is a compact drift key, not a security primitive: a mismatch only degrades safely to page 1.
4109
+ let cursorKeyHash = 0x811c9dc5;
4110
+ for (const value of [...facetSearches.entries()].sort(([a], [b]) => a.localeCompare(b)).flat()) {
4111
+ for (let i = 0; i < value.length; i++) {
4112
+ cursorKeyHash ^= value.charCodeAt(i);
4113
+ cursorKeyHash = Math.imul(cursorKeyHash, 0x01000193);
4114
+ }
4115
+ cursorKeyHash ^= 0;
4116
+ cursorKeyHash = Math.imul(cursorKeyHash, 0x01000193);
4117
+ }
4118
+ const cursorKey = (cursorKeyHash >>> 0).toString(36);
4119
+
4120
+ type SearchCategory = {
4121
+ issueCount: number;
4122
+ pageInfo?: { endCursor?: string | null; hasNextPage: boolean };
4123
+ nodes: (GitHubPullRequest | null)[] | null;
4124
+ };
4125
+ interface SearchCursor {
4126
+ key: string;
4127
+ page: number;
4128
+ facets: Record<string, string | null>;
4129
+ truncated?: boolean;
4130
+ totalCount?: number;
4131
+ }
4132
+
4133
+ let cursor: SearchCursor | undefined;
4134
+ if (options?.cursor != null) {
4135
+ try {
4136
+ const parsed = JSON.parse(options.cursor) as Partial<SearchCursor>;
4137
+ const parsedFacets = parsed.facets;
4138
+ if (parsedFacets != null && typeof parsedFacets === 'object' && !Array.isArray(parsedFacets)) {
4139
+ const parsedAliases = Object.keys(parsedFacets).sort();
4140
+ const sameFacets =
4141
+ parsedAliases.length === facetAliases.length &&
4142
+ parsedAliases.every((alias, index) => alias === facetAliases[index]);
4143
+ const usableSlots = Object.values(parsedFacets).every(
4144
+ slot => slot === null || (typeof slot === 'string' && slot.length > 0),
4145
+ );
4146
+ if (parsed.key === cursorKey && sameFacets && usableSlots) {
4147
+ cursor = {
4148
+ key: cursorKey,
4149
+ page:
4150
+ typeof parsed.page === 'number' && Number.isFinite(parsed.page)
4151
+ ? Math.max(1, Math.trunc(parsed.page))
4152
+ : 1,
4153
+ facets: parsedFacets,
4154
+ truncated: parsed.truncated === true,
4155
+ totalCount:
4156
+ typeof parsed.totalCount === 'number' && Number.isFinite(parsed.totalCount)
4157
+ ? Math.max(0, Math.trunc(parsed.totalCount))
4158
+ : undefined,
4159
+ };
4160
+ }
4161
+ }
4162
+ } catch {}
4163
+ }
4164
+ const page = cursor?.page ?? 1;
4165
+ const isActive = (alias: string): boolean => cursor?.facets[alias] !== null;
4166
+ const activeFacets = facets.filter(f => isActive(f.alias));
4167
+ if (activeFacets.length === 0) {
4168
+ return {
4169
+ values: [],
4170
+ hasMore: false,
4171
+ page: page,
4172
+ truncated: cursor?.truncated === true,
4173
+ totalCount: cursor?.totalCount,
4174
+ };
4175
+ }
4176
+
4177
+ const includeVar = (alias: string): string => `include${alias.charAt(0).toUpperCase()}${alias.slice(1)}`;
4178
+ const params = facets.flatMap(f => [
4179
+ `$${f.alias}Search: String!`,
4180
+ `$${f.alias}Cursor: String`,
4181
+ `$${includeVar(f.alias)}: Boolean!`,
4182
+ ]);
4183
+ const fields = facets.map(
4184
+ f => `${f.alias}: search(first: ${pageSize}, after: $${f.alias}Cursor, query: $${f.alias}Search, type: ISSUE)
4185
+ @include(if: $${includeVar(f.alias)}) {
4186
+ issueCount
4187
+ pageInfo {
4188
+ endCursor
4189
+ hasNextPage
4190
+ }
4191
+ nodes {
4192
+ ... on PullRequest {
4193
+ ${gqlPullRequestFragment}
4194
+ }
4195
+ }
4196
+ }`,
4197
+ );
4198
+ const query = `query searchPullRequestsPage(
4199
+ ${params.join('\n\t\t\t')}
4200
+ $avatarSize: Int
4201
+ ) {
4202
+ ${fields.join('\n\t\t\t')}
4203
+ }`;
4204
+
4205
+ const variables: Record<string, unknown> = {
4206
+ baseUrl: options?.baseUrl,
4207
+ avatarSize: options?.avatarSize,
4208
+ };
4209
+ for (const facet of facets) {
4210
+ variables[`${facet.alias}Search`] = facetSearches.get(facet.alias);
4211
+ variables[`${facet.alias}Cursor`] = cursor?.facets[facet.alias] ?? undefined;
4212
+ variables[includeVar(facet.alias)] = isActive(facet.alias);
4213
+ }
4214
+
4215
+ try {
4216
+ const rsp = await this.graphql<Record<string, SearchCategory | undefined>>(
4217
+ provider,
4218
+ token,
4219
+ query,
4220
+ variables,
4221
+ scope,
4222
+ cancellation,
4223
+ );
4224
+ if (rsp == null) return { values: [], hasMore: false, page: page, truncated: false };
4225
+
4226
+ const pullRequests: PullRequestShape[] = [];
4227
+ for (const facet of activeFacets) {
4228
+ for (const node of rsp[facet.alias]?.nodes ?? []) {
4229
+ if (node?.id == null) continue;
4230
+
4231
+ try {
4232
+ pullRequests.push(fromGitHubPullRequest(node, provider));
4233
+ } catch (ex) {
4234
+ scope?.warn(`skipped unmappable pull request; id=${node.id}, url=${node.url}, ex=${ex}`);
4235
+ }
4236
+ }
4237
+ }
4238
+ pullRequests.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime());
4239
+ const values = [
4240
+ ...uniqueBy(
4241
+ pullRequests,
4242
+ pr => pr.url,
4243
+ (original, _current) => original,
4244
+ ),
4245
+ ];
4246
+
4247
+ const nextFacets: Record<string, string | null> = {};
4248
+ let hasMore = false;
4249
+ let continuationMissing = false;
4250
+ let totalCount = cursor?.totalCount ?? 0;
4251
+ let providerLimitReached = false;
4252
+ for (const facet of facets) {
4253
+ if (!isActive(facet.alias)) {
4254
+ nextFacets[facet.alias] = null;
4255
+ continue;
4256
+ }
4257
+
4258
+ const category = rsp[facet.alias];
4259
+ const endCursor =
4260
+ category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor
4261
+ ? category.pageInfo.endCursor
4262
+ : null;
4263
+ nextFacets[facet.alias] = endCursor;
4264
+ if (endCursor != null) {
4265
+ hasMore = true;
4266
+ }
4267
+ if (category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor == null) {
4268
+ continuationMissing = true;
4269
+ }
4270
+ totalCount = Math.max(totalCount, category?.issueCount ?? 0);
4271
+ providerLimitReached ||= (category?.issueCount ?? 0) > githubSearchResultLimit;
4272
+ }
4273
+ const truncated = cursor?.truncated === true || providerLimitReached || continuationMissing;
4274
+ const next: SearchCursor = {
4275
+ key: cursorKey,
4276
+ page: page + 1,
4277
+ facets: nextFacets,
4278
+ truncated: truncated || undefined,
4279
+ totalCount: totalCount,
4280
+ };
4281
+
4282
+ return {
4283
+ values: values,
4284
+ cursor: hasMore ? JSON.stringify(next) : undefined,
4285
+ hasMore: hasMore,
4286
+ page: page,
4287
+ truncated: truncated,
4288
+ totalCount: totalCount,
4289
+ };
4290
+ } catch (ex) {
4291
+ throw this.handleException(ex, provider, scope);
4292
+ }
4293
+ }
4294
+
4025
4295
  @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
4026
4296
  async searchPullRequests(
4027
4297
  provider: Provider,
@@ -0,0 +1,80 @@
1
+ import type { PullRequestSearchCriteria, PullRequestStateFilter } from '../../../git/models/pullRequest.js';
2
+ import { PullRequestFilter } from '../../../git/models/pullRequest.js';
3
+ import { sanitizeGitHubSearchText } from './issueSearchQuery.js';
4
+
5
+ export type GitHubPullRequestSearchFacet = {
6
+ /** Stable GraphQL alias and composite-cursor key. */
7
+ alias: string;
8
+ qualifiers: string[];
9
+ };
10
+
11
+ const relationshipQualifier: Record<PullRequestFilter, string> = {
12
+ [PullRequestFilter.Author]: 'author:@me',
13
+ [PullRequestFilter.Assignee]: 'assignee:@me',
14
+ [PullRequestFilter.ReviewRequested]: 'review-requested:@me',
15
+ [PullRequestFilter.Mention]: 'mentions:@me',
16
+ };
17
+
18
+ const relationshipAlias: Record<PullRequestFilter, string> = {
19
+ [PullRequestFilter.Author]: 'author',
20
+ [PullRequestFilter.Assignee]: 'assignee',
21
+ [PullRequestFilter.ReviewRequested]: 'reviewRequested',
22
+ [PullRequestFilter.Mention]: 'mention',
23
+ };
24
+
25
+ const stateAlias: Record<PullRequestStateFilter, string> = {
26
+ open: 'Open',
27
+ closed: 'Closed',
28
+ merged: 'Merged',
29
+ all: 'All',
30
+ };
31
+
32
+ function stateQualifiers(state: PullRequestStateFilter): string[] {
33
+ switch (state) {
34
+ case 'closed':
35
+ // GitHub's `is:closed` includes merged PRs; `is:unmerged` removes that subset.
36
+ return ['is:closed', 'is:unmerged'];
37
+ case 'merged':
38
+ return ['is:merged'];
39
+ case 'all':
40
+ return [];
41
+ case 'open':
42
+ return ['is:open'];
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Translates provider-neutral PR criteria into the independent GitHub searches whose union answers the request.
48
+ *
49
+ * GitHub's API search syntax ANDs repeated relationship/state qualifiers and does not implement the boolean
50
+ * groups accepted by the newer web UI. Each requested relationship × state pair therefore becomes one GraphQL
51
+ * alias; the API client sends every active alias in a single document, preserving one HTTP request per page.
52
+ * An omitted relationship is the explicitly unfiltered repository/organization scope, never `involves:@me`.
53
+ */
54
+ export function toGitHubPullRequestSearchFacets(
55
+ criteria: PullRequestSearchCriteria | undefined,
56
+ ): GitHubPullRequestSearchFacet[] {
57
+ const relationships: (PullRequestFilter | undefined)[] = criteria?.relationships?.length
58
+ ? [...new Set(criteria.relationships)]
59
+ : [undefined];
60
+ const requestedStates: PullRequestStateFilter[] = criteria?.states?.length
61
+ ? [...new Set(criteria.states)]
62
+ : ['open'];
63
+ const states: PullRequestStateFilter[] = requestedStates.includes('all') ? ['all'] : requestedStates;
64
+ const text = criteria?.text != null ? sanitizeGitHubSearchText(criteria.text) : '';
65
+
66
+ return relationships.flatMap(relationship =>
67
+ states.map(state => ({
68
+ alias: `${relationship != null ? relationshipAlias[relationship] : 'scope'}${stateAlias[state]}`,
69
+ qualifiers: [
70
+ 'is:pr',
71
+ ...(relationship != null ? [relationshipQualifier[relationship]] : []),
72
+ ...stateQualifiers(state),
73
+ ...(criteria?.includeArchived === true ? [] : ['archived:false']),
74
+ ...(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',
77
+ ],
78
+ })),
79
+ );
80
+ }
@@ -218,6 +218,8 @@ export { IssueFilter, PullRequestFilter } from './providerFilters.js';
218
218
  // The filtered issue search's criteria model and the capability table that says which of it a provider honors.
219
219
  // String unions and plain interfaces, so a consumer builds criteria without importing an internal subpath.
220
220
  export type { IssueSearchCapabilities, IssueSearchCriteria, IssueSearchRelationship } from './providerFilters.js';
221
+ // Account-wide pull-request search criteria and the per-provider capability table.
222
+ export type { PullRequestSearchCapabilities, PullRequestSearchCriteria } from './providerFilters.js';
221
223
  // The count-only issue probe's input and result shapes.
222
224
  export type { IssueCountResult, IssueCountScope } from './reads/counts.js';
223
225
  // Cross-provider PR/issue state filters (string unions in the git models).
@@ -1,6 +1,11 @@
1
1
  import type { Account } from '../../git/models/author.js';
2
2
  import type { IssueSearchCriteria, IssueShape } from '../../git/models/issue.js';
3
- import type { PullRequest, PullRequestShape, PullRequestStateFilter } from '../../git/models/pullRequest.js';
3
+ import type {
4
+ PullRequest,
5
+ PullRequestSearchCriteria,
6
+ PullRequestShape,
7
+ PullRequestStateFilter,
8
+ } from '../../git/models/pullRequest.js';
4
9
  import type { GitRemote } from '../../git/models/remote.js';
5
10
  import type { RemoteProviderId } from '../../git/models/remoteProvider.js';
6
11
  import type { ResourceDescriptor } from '../../git/models/resourceDescriptor.js';
@@ -67,6 +72,7 @@ import type {
67
72
  ProviderReposInput,
68
73
  ProviderRepositoryShape,
69
74
  PullRequestFilter,
75
+ PullRequestSearchCapabilities,
70
76
  } from './providers/models.js';
71
77
  import { providersMetadata } from './providers/models.js';
72
78
  import type { ProvidersApi } from './providers/providersApi.js';
@@ -81,6 +87,7 @@ import { listIssueTrackerIssuesPage } from './reads/issueTracker.js';
81
87
  import { listPullRequestsPage } from './reads/pullRequests.js';
82
88
  import { resolveRepository } from './reads/resolveRepository.js';
83
89
  import { searchIssuesPage } from './reads/searchIssues.js';
90
+ import { searchPullRequestsPage } from './reads/searchPullRequests.js';
84
91
  import { sweepClosedPullRequests, sweepPullRequests } from './reads/sweeps.js';
85
92
  import { noConnectionWarning } from './reads/warnings.js';
86
93
  import type {
@@ -533,6 +540,7 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
533
540
  getSupportedFilters(providerId: IntegrationIds): {
534
541
  pullRequests: PullRequestFilter[];
535
542
  pullRequestsAccountWide: PullRequestFilter[];
543
+ pullRequestSearch: PullRequestSearchCapabilities;
536
544
  issues: IssueFilter[];
537
545
  issuesAccountWide: IssueFilter[];
538
546
  issueSearch: IssueSearchCapabilities;
@@ -1051,6 +1059,22 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1051
1059
  return listPullRequestsPage(this, options);
1052
1060
  }
1053
1061
 
1062
+ /** See {@link IntegrationManager.searchPullRequestsPage}. */
1063
+ async searchPullRequestsPage(options: {
1064
+ providerId: IntegrationIds;
1065
+ repos?: ProviderReposInput;
1066
+ org?: string;
1067
+ criteria?: PullRequestSearchCriteria;
1068
+ page?: number;
1069
+ cursor?: string;
1070
+ itemsPerPage?: number;
1071
+ forceSync?: boolean;
1072
+ connectionId?: string;
1073
+ domain?: string;
1074
+ }): Promise<ProviderPagedResult<PullRequestShape>> {
1075
+ return searchPullRequestsPage(this, options);
1076
+ }
1077
+
1054
1078
  async listIssuesPage(options: {
1055
1079
  providerId: IntegrationIds;
1056
1080
  repos?: ProviderReposInput;
@@ -1550,12 +1574,31 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1550
1574
  }
1551
1575
  }
1552
1576
 
1577
+ // Concurrent, not serial: each `syncCloudConnection` is a cloud round trip (a forced sync deletes the
1578
+ // stored session and refetches it), so a serial loop pays the SUM of every provider's latency on a
1579
+ // path that gates EVERY provider read. `reconcileCloudConnections` below is already `Promise.all`.
1580
+ //
1581
+ // Safe because every shared mutable path is already serialized, which is why this can be a plain
1582
+ // fan-out rather than needing per-provider grouping:
1583
+ // - `ensureProvider` is `@gate()`d on `providerId`, so the two integrations a multi-host
1584
+ // self-managed id yields (one per domain, see `getSupportedCloudIntegrations`) share one
1585
+ // in-flight construction instead of racing to `providers.set`.
1586
+ // - `ensureSession` is `@gate()`d per integration instance.
1587
+ // - `addOrUpdateConfigured`/`removeConfigured` mutate `configured` in a synchronous critical
1588
+ // section (no `await` between read and `set`), and `storeConfigured` has its own write queue.
1589
+ // - Secrets and the `connected:` workspace flags are keyed by integration id + domain.
1590
+ const integrations: Integration[] = [];
1553
1591
  for await (const integration of this.getSupportedCloudIntegrations(domainsById)) {
1554
- await integration.syncCloudConnection(
1555
- this.getCloudConnectionState(integration, connectedIntegrations, domainsById),
1556
- forceConnect,
1557
- );
1592
+ integrations.push(integration);
1558
1593
  }
1594
+ await Promise.all(
1595
+ integrations.map(integration =>
1596
+ integration.syncCloudConnection(
1597
+ this.getCloudConnectionState(integration, connectedIntegrations, domainsById),
1598
+ forceConnect,
1599
+ ),
1600
+ ),
1601
+ );
1559
1602
 
1560
1603
  // Persist every account when the backend advertises per-connection identity (multi-account). This
1561
1604
  // is a strict no-op for backends that return a single, id-less connection per provider.
@@ -1,10 +1,19 @@
1
1
  import type { IssueSearchCriteria, IssueShape } from '../../git/models/issue.js';
2
- import type { PullRequestShape, PullRequestStateFilter } from '../../git/models/pullRequest.js';
2
+ import type {
3
+ PullRequestSearchCriteria,
4
+ PullRequestShape,
5
+ PullRequestStateFilter,
6
+ } from '../../git/models/pullRequest.js';
3
7
  import type { Event } from '../../utils/event.js';
4
8
  import type { ConfiguredIntegrationsChangeEvent } from './authentication/configuredIntegrationService.js';
5
9
  import type { ConfiguredIntegrationDescriptor } from './authentication/models.js';
6
10
  import type { IntegrationIds } from './constants.js';
7
- import type { IssueFilter, IssueSearchCapabilities, PullRequestFilter } from './providerFilters.js';
11
+ import type {
12
+ IssueFilter,
13
+ IssueSearchCapabilities,
14
+ PullRequestFilter,
15
+ PullRequestSearchCapabilities,
16
+ } from './providerFilters.js';
8
17
  import type { IssueCountResult, IssueCountScope } from './reads/counts.js';
9
18
  import type {
10
19
  ConnectionStateChangeEvent,
@@ -142,8 +151,9 @@ export type ListProjectsOptions =
142
151
  *
143
152
  * ## Paging contract
144
153
  *
145
- * Every paged read (`listRepos`, `listPullRequestsPage`, `listIssuesPage`, `listIssueTrackerIssuesPage`,
146
- * `broadenIssues`) takes both `page` and `cursor`, and the two are NOT interchangeable:
154
+ * Every paged read (`listRepos`, `listPullRequestsPage`, `searchPullRequestsPage`, `listIssuesPage`,
155
+ * `listIssueTrackerIssuesPage`, `broadenIssues`) takes both `page` and `cursor`, and the two are NOT
156
+ * interchangeable:
147
157
  *
148
158
  * - **Supplying `cursor` guarantees a single upstream round trip per provider scope.** A threaded continuation
149
159
  * is handed to the provider as-is; the facade never walks the pages before it. This is a guarantee, not an
@@ -199,6 +209,11 @@ export interface IntegrationManager {
199
209
  pullRequests: PullRequestFilter[];
200
210
  /** Optional for structural compatibility; missing means no account-wide narrowing filters are supported. */
201
211
  pullRequestsAccountWide?: PullRequestFilter[];
212
+ /**
213
+ * Criteria and scopes {@link searchPullRequestsPage} can express. Always present; an empty relationship list
214
+ * means the provider exposes no filtered pull-request search.
215
+ */
216
+ pullRequestSearch: PullRequestSearchCapabilities;
202
217
  issues: IssueFilter[];
203
218
  issuesAccountWide: IssueFilter[];
204
219
  /**
@@ -274,6 +289,42 @@ export interface IntegrationManager {
274
289
  */
275
290
  domain?: string;
276
291
  }): Promise<ProviderPagedResult<PullRequestShape>>;
292
+ /**
293
+ * Pull requests matching structured criteria over a repository/organization or current-user relationship
294
+ * scope.
295
+ *
296
+ * This is a separate read from {@link listPullRequestsPage}: free text reaches the provider instead of filtering
297
+ * whichever rows happened to be loaded, and every cursor-threaded page costs exactly one upstream request.
298
+ * `criteria.relationships` and `criteria.states` are OR sets, so `[closed, merged]` expresses the complete
299
+ * terminal set and `[Author, Assignee, ReviewRequested]` matches the visible PR list without falling back to
300
+ * GitHub's mismatched `involves:@me`. Omit relationships only with a repository/organization scope to search
301
+ * every PR there.
302
+ *
303
+ * Results are always ordered most-recently-updated-first. If the provider's result ceiling is reached, the
304
+ * request still succeeds and carries a warning omission with `totalCount`, `limit`, and `recovery: 'none'`.
305
+ * `totalCount` is the largest provider-reported pre-ceiling facet count, not the reachable or returned row
306
+ * count; this mirrors the per-search ceiling's own unit.
307
+ *
308
+ * Check `getSupportedFilters().pullRequestSearch` before calling. A provider that reports no relationships
309
+ * refuses the read rather than returning a page that never honored the criteria or scope.
310
+ */
311
+ searchPullRequestsPage(options: {
312
+ providerId: IntegrationIds;
313
+ /** Repository descriptors that bound the search; ids cannot name provider search qualifiers. */
314
+ repos?: ProviderRepositoriesInput;
315
+ /** Organization/account that bounds the search. */
316
+ org?: string;
317
+ criteria?: PullRequestSearchCriteria;
318
+ /** Cursor-only: without a cursor, reaching page N costs O(N) upstream requests. */
319
+ page?: number;
320
+ cursor?: string;
321
+ /** Page size per relationship × state facet; the deduped union can contain more rows. */
322
+ itemsPerPage?: number;
323
+ forceSync?: boolean;
324
+ connectionId?: string;
325
+ /** Self-managed host domain fallback; see {@link ProviderSweepTarget.domain}. */
326
+ domain?: string;
327
+ }): Promise<ProviderPagedResult<PullRequestShape>>;
277
328
  listIssuesPage(options: {
278
329
  providerId: IntegrationIds;
279
330
  repos?: ProviderRepositoriesInput;
@@ -3,7 +3,12 @@ import type { Account, UnidentifiedAuthor } from '../../../git/models/author.js'
3
3
  import type { DefaultBranch } from '../../../git/models/defaultBranch.js';
4
4
  import type { IssueSearchCriteria, IssueShape, IssueStateFilter } from '../../../git/models/issue.js';
5
5
  import type { IssueOrPullRequestState as PullRequestState } from '../../../git/models/issueOrPullRequest.js';
6
- import type { PullRequest, PullRequestMergeMethod, PullRequestStateFilter } from '../../../git/models/pullRequest.js';
6
+ import type {
7
+ PullRequest,
8
+ PullRequestMergeMethod,
9
+ PullRequestSearchCriteria,
10
+ PullRequestStateFilter,
11
+ } from '../../../git/models/pullRequest.js';
7
12
  import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata.js';
8
13
  import type { ResourceDescriptor } from '../../../git/models/resourceDescriptor.js';
9
14
  import type { PullRequestUrlIdentity } from '../../../git/utils/pullRequest.utils.js';
@@ -44,7 +49,12 @@ import {
44
49
  } from '../providers/models.js';
45
50
  import type { ProvidersApi } from '../providers/providersApi.js';
46
51
  import { mergeCollectionMetadata } from '../providers/utils/providerPaging.js';
47
- import type { IntegrationResult, IntegrationType, ProviderIssueSearchPage } from './integration.js';
52
+ import type {
53
+ IntegrationResult,
54
+ IntegrationType,
55
+ ProviderIssueSearchPage,
56
+ ProviderPullRequestSearchPage,
57
+ } from './integration.js';
48
58
  import { IntegrationBase } from './integration.js';
49
59
 
50
60
  function isAzureDevOpsProvider(
@@ -1543,6 +1553,50 @@ export abstract class GitHostIntegration<
1543
1553
  options?: SearchMyPullRequestsOptions,
1544
1554
  ): Promise<IntegrationResult<PullRequest[] | undefined>>;
1545
1555
 
1556
+ /**
1557
+ * Result-returning wrapper for the filtered pull-request search. Errors become the soft
1558
+ * `{ error }` branch so the public facade can preserve an empty account and a failed request as distinct
1559
+ * outcomes.
1560
+ */
1561
+ async searchPullRequestsPageResult(
1562
+ options: {
1563
+ repos?: ProviderRepoInput[];
1564
+ org?: string;
1565
+ criteria?: PullRequestSearchCriteria;
1566
+ cursor?: string;
1567
+ pageSize?: number;
1568
+ },
1569
+ cancellation?: AbortSignal,
1570
+ connectionId?: string,
1571
+ ): Promise<IntegrationResult<ProviderPullRequestSearchPage | undefined>> {
1572
+ const scope = getScopedLogger();
1573
+ const session = await this.resolveReadSession(connectionId, scope);
1574
+ if (session == null) return undefined;
1575
+
1576
+ const start = performance.now();
1577
+ try {
1578
+ const result = await this.searchProviderPullRequestsPage?.(session, options, cancellation);
1579
+ this.resetRequestExceptionCount('searchPullRequestsPage');
1580
+ return { value: result, duration: performance.now() - start };
1581
+ } catch (ex) {
1582
+ this.handleProviderException('searchPullRequestsPage', ex, { scope: scope, connectionId: connectionId });
1583
+ return { error: toError(ex), duration: performance.now() - start };
1584
+ }
1585
+ }
1586
+
1587
+ /** Must agree with `ProviderMetadata.supportedPullRequestSearch`. */
1588
+ protected searchProviderPullRequestsPage?(
1589
+ session: ProviderAuthenticationSession,
1590
+ options: {
1591
+ repos?: ProviderRepoInput[];
1592
+ org?: string;
1593
+ criteria?: PullRequestSearchCriteria;
1594
+ cursor?: string;
1595
+ pageSize?: number;
1596
+ },
1597
+ cancellation?: AbortSignal,
1598
+ ): Promise<ProviderPullRequestSearchPage | undefined>;
1599
+
1546
1600
  async searchPullRequests(
1547
1601
  searchQuery: string,
1548
1602
  repo?: T,