@gitkraken/core-gitlens 0.5.105 → 0.5.106

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 (75) hide show
  1. package/CHANGELOG.md +8 -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 +33 -1
  7. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  8. package/dist/plus/git-github/api/github.js +176 -0
  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 +5 -0
  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.map +1 -1
  29. package/dist/plus/integrations/models/pullRequestReads.d.ts +18 -0
  30. package/dist/plus/integrations/models/pullRequestReads.d.ts.map +1 -0
  31. package/dist/plus/integrations/models/pullRequestReads.js +2 -0
  32. package/dist/plus/integrations/models/pullRequestReads.js.map +1 -0
  33. package/dist/plus/integrations/providerFilters.d.ts +7 -6
  34. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  35. package/dist/plus/integrations/providerFilters.js +1 -7
  36. package/dist/plus/integrations/providerFilters.js.map +1 -1
  37. package/dist/plus/integrations/providers/github.d.ts +14 -2
  38. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  39. package/dist/plus/integrations/providers/github.js +15 -0
  40. package/dist/plus/integrations/providers/github.js.map +1 -1
  41. package/dist/plus/integrations/providers/models.d.ts +12 -2
  42. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  43. package/dist/plus/integrations/providers/models.js +18 -0
  44. package/dist/plus/integrations/providers/models.js.map +1 -1
  45. package/dist/plus/integrations/reads/context.d.ts +1 -1
  46. package/dist/plus/integrations/reads/filters.d.ts +34 -1
  47. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  48. package/dist/plus/integrations/reads/filters.js +73 -0
  49. package/dist/plus/integrations/reads/filters.js.map +1 -1
  50. package/dist/plus/integrations/reads/searchPullRequests.d.ts +26 -0
  51. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -0
  52. package/dist/plus/integrations/reads/searchPullRequests.js +108 -0
  53. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -0
  54. package/dist/plus/integrations/reads/warnings.d.ts +6 -2
  55. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  56. package/dist/plus/integrations/reads/warnings.js +40 -0
  57. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  58. package/docs/integrations.md +53 -4
  59. package/package.json +1 -1
  60. package/src/git/models/pullRequest.ts +50 -0
  61. package/src/plus/git-github/api/github.ts +254 -1
  62. package/src/plus/git-github/api/pullRequestSearchQuery.ts +80 -0
  63. package/src/plus/integrations/index.ts +2 -0
  64. package/src/plus/integrations/integrationService.ts +25 -1
  65. package/src/plus/integrations/manager.ts +55 -4
  66. package/src/plus/integrations/models/gitHostIntegration.ts +56 -2
  67. package/src/plus/integrations/models/integration.ts +2 -0
  68. package/src/plus/integrations/models/pullRequestReads.ts +18 -0
  69. package/src/plus/integrations/providerFilters.ts +8 -6
  70. package/src/plus/integrations/providers/github.ts +37 -1
  71. package/src/plus/integrations/providers/models.ts +37 -2
  72. package/src/plus/integrations/reads/context.ts +1 -1
  73. package/src/plus/integrations/reads/filters.ts +105 -0
  74. package/src/plus/integrations/reads/searchPullRequests.ts +210 -0
  75. 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
@@ -4022,6 +4052,229 @@ export class GitHubApi {
4022
4052
  }
4023
4053
  }
4024
4054
 
4055
+ /**
4056
+ * Searches pull requests over a repository/org or current-user relationship scope. One GraphQL document carries
4057
+ * every active relationship × state facet, so one HTTP request serves one page even when the logical search is
4058
+ * a union. The cursor preserves each facet's continuation plus the positional page.
4059
+ *
4060
+ * Ordering is always most-recently-updated-first, and user text is sanitized before it reaches the provider
4061
+ * query. See {@link toGitHubPullRequestSearchFacets}.
4062
+ */
4063
+ @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
4064
+ async searchPullRequestsPage(
4065
+ provider: Provider,
4066
+ token: GitHubTokenInfo,
4067
+ options?: {
4068
+ repos?: string[];
4069
+ org?: string;
4070
+ criteria?: PullRequestSearchCriteria;
4071
+ baseUrl?: string;
4072
+ avatarSize?: number;
4073
+ cursor?: string;
4074
+ pageSize?: number;
4075
+ },
4076
+ cancellation?: AbortSignal,
4077
+ ): Promise<PullRequestSearchResult | undefined> {
4078
+ const scope = getScopedLogger();
4079
+
4080
+ const pageSize = Math.min(
4081
+ maxPullRequestSearchPageSize,
4082
+ Math.max(1, Math.trunc(options?.pageSize ?? defaultPullRequestSearchPageSize)),
4083
+ );
4084
+ const facets = toGitHubPullRequestSearchFacets(options?.criteria);
4085
+ const facetAliases = facets.map(f => f.alias).sort();
4086
+ const scopeQualifiers = toGitHubIssueSearchScopeQualifiers(options?.org, options?.repos);
4087
+ const facetSearches = new Map(
4088
+ facets.map(f => [f.alias, [...scopeQualifiers, ...f.qualifiers].join(' ')] as const),
4089
+ );
4090
+ // Bind a cursor to every qualifier without publishing the user text/scope inside the opaque cursor. FNV-1a
4091
+ // is a compact drift key, not a security primitive: a mismatch only degrades safely to page 1.
4092
+ let cursorKeyHash = 0x811c9dc5;
4093
+ for (const value of [...facetSearches.entries()].sort(([a], [b]) => a.localeCompare(b)).flat()) {
4094
+ for (let i = 0; i < value.length; i++) {
4095
+ cursorKeyHash ^= value.charCodeAt(i);
4096
+ cursorKeyHash = Math.imul(cursorKeyHash, 0x01000193);
4097
+ }
4098
+ cursorKeyHash ^= 0;
4099
+ cursorKeyHash = Math.imul(cursorKeyHash, 0x01000193);
4100
+ }
4101
+ const cursorKey = (cursorKeyHash >>> 0).toString(36);
4102
+
4103
+ type SearchCategory = {
4104
+ issueCount: number;
4105
+ pageInfo?: { endCursor?: string | null; hasNextPage: boolean };
4106
+ nodes: (GitHubPullRequest | null)[] | null;
4107
+ };
4108
+ interface SearchCursor {
4109
+ key: string;
4110
+ page: number;
4111
+ facets: Record<string, string | null>;
4112
+ truncated?: boolean;
4113
+ totalCount?: number;
4114
+ }
4115
+
4116
+ let cursor: SearchCursor | undefined;
4117
+ if (options?.cursor != null) {
4118
+ try {
4119
+ const parsed = JSON.parse(options.cursor) as Partial<SearchCursor>;
4120
+ const parsedFacets = parsed.facets;
4121
+ if (parsedFacets != null && typeof parsedFacets === 'object' && !Array.isArray(parsedFacets)) {
4122
+ const parsedAliases = Object.keys(parsedFacets).sort();
4123
+ const sameFacets =
4124
+ parsedAliases.length === facetAliases.length &&
4125
+ parsedAliases.every((alias, index) => alias === facetAliases[index]);
4126
+ const usableSlots = Object.values(parsedFacets).every(
4127
+ slot => slot === null || (typeof slot === 'string' && slot.length > 0),
4128
+ );
4129
+ if (parsed.key === cursorKey && sameFacets && usableSlots) {
4130
+ cursor = {
4131
+ key: cursorKey,
4132
+ page:
4133
+ typeof parsed.page === 'number' && Number.isFinite(parsed.page)
4134
+ ? Math.max(1, Math.trunc(parsed.page))
4135
+ : 1,
4136
+ facets: parsedFacets,
4137
+ truncated: parsed.truncated === true,
4138
+ totalCount:
4139
+ typeof parsed.totalCount === 'number' && Number.isFinite(parsed.totalCount)
4140
+ ? Math.max(0, Math.trunc(parsed.totalCount))
4141
+ : undefined,
4142
+ };
4143
+ }
4144
+ }
4145
+ } catch {}
4146
+ }
4147
+ const page = cursor?.page ?? 1;
4148
+ const isActive = (alias: string): boolean => cursor?.facets[alias] !== null;
4149
+ const activeFacets = facets.filter(f => isActive(f.alias));
4150
+ if (activeFacets.length === 0) {
4151
+ return {
4152
+ values: [],
4153
+ hasMore: false,
4154
+ page: page,
4155
+ truncated: cursor?.truncated === true,
4156
+ totalCount: cursor?.totalCount,
4157
+ };
4158
+ }
4159
+
4160
+ const includeVar = (alias: string): string => `include${alias.charAt(0).toUpperCase()}${alias.slice(1)}`;
4161
+ const params = facets.flatMap(f => [
4162
+ `$${f.alias}Search: String!`,
4163
+ `$${f.alias}Cursor: String`,
4164
+ `$${includeVar(f.alias)}: Boolean!`,
4165
+ ]);
4166
+ const fields = facets.map(
4167
+ f => `${f.alias}: search(first: ${pageSize}, after: $${f.alias}Cursor, query: $${f.alias}Search, type: ISSUE)
4168
+ @include(if: $${includeVar(f.alias)}) {
4169
+ issueCount
4170
+ pageInfo {
4171
+ endCursor
4172
+ hasNextPage
4173
+ }
4174
+ nodes {
4175
+ ... on PullRequest {
4176
+ ${gqlPullRequestFragment}
4177
+ }
4178
+ }
4179
+ }`,
4180
+ );
4181
+ const query = `query searchPullRequestsPage(
4182
+ ${params.join('\n\t\t\t')}
4183
+ $avatarSize: Int
4184
+ ) {
4185
+ ${fields.join('\n\t\t\t')}
4186
+ }`;
4187
+
4188
+ const variables: Record<string, unknown> = {
4189
+ baseUrl: options?.baseUrl,
4190
+ avatarSize: options?.avatarSize,
4191
+ };
4192
+ for (const facet of facets) {
4193
+ variables[`${facet.alias}Search`] = facetSearches.get(facet.alias);
4194
+ variables[`${facet.alias}Cursor`] = cursor?.facets[facet.alias] ?? undefined;
4195
+ variables[includeVar(facet.alias)] = isActive(facet.alias);
4196
+ }
4197
+
4198
+ try {
4199
+ const rsp = await this.graphql<Record<string, SearchCategory | undefined>>(
4200
+ provider,
4201
+ token,
4202
+ query,
4203
+ variables,
4204
+ scope,
4205
+ cancellation,
4206
+ );
4207
+ if (rsp == null) return { values: [], hasMore: false, page: page, truncated: false };
4208
+
4209
+ const pullRequests: PullRequestShape[] = [];
4210
+ for (const facet of activeFacets) {
4211
+ for (const node of rsp[facet.alias]?.nodes ?? []) {
4212
+ if (node?.id == null) continue;
4213
+
4214
+ try {
4215
+ pullRequests.push(fromGitHubPullRequest(node, provider));
4216
+ } catch (ex) {
4217
+ scope?.warn(`skipped unmappable pull request; id=${node.id}, url=${node.url}, ex=${ex}`);
4218
+ }
4219
+ }
4220
+ }
4221
+ pullRequests.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime());
4222
+ const values = [
4223
+ ...uniqueBy(
4224
+ pullRequests,
4225
+ pr => pr.url,
4226
+ (original, _current) => original,
4227
+ ),
4228
+ ];
4229
+
4230
+ const nextFacets: Record<string, string | null> = {};
4231
+ let hasMore = false;
4232
+ let continuationMissing = false;
4233
+ let totalCount = cursor?.totalCount ?? 0;
4234
+ let providerLimitReached = false;
4235
+ for (const facet of facets) {
4236
+ if (!isActive(facet.alias)) {
4237
+ nextFacets[facet.alias] = null;
4238
+ continue;
4239
+ }
4240
+
4241
+ const category = rsp[facet.alias];
4242
+ const endCursor =
4243
+ category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor
4244
+ ? category.pageInfo.endCursor
4245
+ : null;
4246
+ nextFacets[facet.alias] = endCursor;
4247
+ if (endCursor != null) {
4248
+ hasMore = true;
4249
+ }
4250
+ if (category?.pageInfo?.hasNextPage === true && category.pageInfo.endCursor == null) {
4251
+ continuationMissing = true;
4252
+ }
4253
+ totalCount = Math.max(totalCount, category?.issueCount ?? 0);
4254
+ providerLimitReached ||= (category?.issueCount ?? 0) > githubSearchResultLimit;
4255
+ }
4256
+ const truncated = cursor?.truncated === true || providerLimitReached || continuationMissing;
4257
+ const next: SearchCursor = {
4258
+ key: cursorKey,
4259
+ page: page + 1,
4260
+ facets: nextFacets,
4261
+ truncated: truncated || undefined,
4262
+ totalCount: totalCount,
4263
+ };
4264
+
4265
+ return {
4266
+ values: values,
4267
+ cursor: hasMore ? JSON.stringify(next) : undefined,
4268
+ hasMore: hasMore,
4269
+ page: page,
4270
+ truncated: truncated,
4271
+ totalCount: totalCount,
4272
+ };
4273
+ } catch (ex) {
4274
+ throw this.handleException(ex, provider, scope);
4275
+ }
4276
+ }
4277
+
4025
4278
  @trace({ args: (provider, token) => ({ provider: provider.name, token: `<token:${token.microHash}>` }) })
4026
4279
  async searchPullRequests(
4027
4280
  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;
@@ -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,
@@ -42,6 +42,7 @@ export type IntegrationType = 'git' | 'issues';
42
42
  // The issue-read contracts live in their own module (pure data, and their relationship to each other is the
43
43
  // point of reading them together); re-exported here so the providers that implement these reads keep one import.
44
44
  export type { AccountWideIssuesResult, ProviderIssueSearchPage, SearchMyIssuesOptions } from './issueReads.js';
45
+ export type { ProviderPullRequestSearchPage } from './pullRequestReads.js';
45
46
 
46
47
  export type IntegrationKey<T extends IntegrationIds = IntegrationIds> = T extends
47
48
  | GitCloudHostIntegrationId
@@ -82,6 +83,7 @@ type SyncReqUsecase = Exclude<
82
83
  | 'countIssues'
83
84
  | 'mergePullRequest'
84
85
  | 'searchIssuesPage'
86
+ | 'searchPullRequestsPage'
85
87
  | 'searchMyIssues'
86
88
  | 'searchMyPullRequests'
87
89
  | 'searchPullRequests',
@@ -0,0 +1,18 @@
1
+ import type { PullRequestShape } from '../../../git/models/pullRequest.js';
2
+
3
+ /**
4
+ * One provider page of the filtered pull-request search, before the facade turns it into a neutral
5
+ * paged result with warnings.
6
+ *
7
+ * `totalCount` is the provider-reported pre-ceiling facet count, not the number of rows that remain reachable
8
+ * after a provider limit. It stays optional because absence means "not reported", never zero. Together with
9
+ * `truncated` it lets the facade distinguish GitHub's quantified ceiling from another unusable continuation.
10
+ */
11
+ export type ProviderPullRequestSearchPage = {
12
+ values: PullRequestShape[];
13
+ truncated: boolean;
14
+ cursor?: string;
15
+ hasMore: boolean;
16
+ page: number;
17
+ totalCount?: number;
18
+ };
@@ -1,9 +1,4 @@
1
- export enum PullRequestFilter {
2
- Author = 'author',
3
- Assignee = 'assignee',
4
- ReviewRequested = 'review-requested',
5
- Mention = 'mention',
6
- }
1
+ export { PullRequestFilter } from '../../git/models/pullRequest.js';
7
2
 
8
3
  export enum IssueFilter {
9
4
  Author = 'author',
@@ -25,3 +20,10 @@ export type {
25
20
  IssueSearchCriteria,
26
21
  IssueSearchRelationship,
27
22
  } from '../../git/models/issue.js';
23
+
24
+ /**
25
+ * The filtered pull-request search criteria and its capability table. These live in `@gitkraken/core-gitlens/git` for the
26
+ * same dependency-direction reason as the issue-search model above: provider API clients translate them below
27
+ * this package and cannot import this facade.
28
+ */
29
+ export type { PullRequestSearchCapabilities, PullRequestSearchCriteria } from '../../git/models/pullRequest.js';