@gitkraken/core-gitlens 0.4.100 → 0.4.101

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 (65) hide show
  1. package/CHANGELOG.md +4 -1
  2. package/dist/plus/integrations/errors.d.ts.map +1 -1
  3. package/dist/plus/integrations/errors.js +6 -2
  4. package/dist/plus/integrations/errors.js.map +1 -1
  5. package/dist/plus/integrations/integrationService.d.ts +8 -5
  6. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  7. package/dist/plus/integrations/integrationService.js +20 -652
  8. package/dist/plus/integrations/integrationService.js.map +1 -1
  9. package/dist/plus/integrations/models/gitHostIntegration.d.ts +10 -17
  10. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  11. package/dist/plus/integrations/models/gitHostIntegration.js +38 -88
  12. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  13. package/dist/plus/integrations/models/issuesIntegration.d.ts +4 -21
  14. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  15. package/dist/plus/integrations/models/issuesIntegration.js +5 -47
  16. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  17. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  18. package/dist/plus/integrations/reads/broaden.d.ts +20 -0
  19. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -0
  20. package/dist/plus/integrations/reads/broaden.js +326 -0
  21. package/dist/plus/integrations/reads/broaden.js.map +1 -0
  22. package/dist/plus/integrations/reads/context.d.ts +22 -0
  23. package/dist/plus/integrations/reads/context.d.ts.map +1 -1
  24. package/dist/plus/integrations/reads/hierarchy.d.ts +1 -1
  25. package/dist/plus/integrations/reads/hierarchy.d.ts.map +1 -1
  26. package/dist/plus/integrations/reads/hierarchy.js +109 -137
  27. package/dist/plus/integrations/reads/hierarchy.js.map +1 -1
  28. package/dist/plus/integrations/reads/hierarchy.utils.d.ts +25 -1
  29. package/dist/plus/integrations/reads/hierarchy.utils.d.ts.map +1 -1
  30. package/dist/plus/integrations/reads/hierarchy.utils.js +43 -0
  31. package/dist/plus/integrations/reads/hierarchy.utils.js.map +1 -1
  32. package/dist/plus/integrations/reads/issueTracker.d.ts +1 -1
  33. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  34. package/dist/plus/integrations/reads/issueTracker.js +13 -25
  35. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  36. package/dist/plus/integrations/reads/issues.d.ts +2 -2
  37. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  38. package/dist/plus/integrations/reads/issues.js.map +1 -1
  39. package/dist/plus/integrations/reads/pullRequests.d.ts +1 -1
  40. package/dist/plus/integrations/reads/pullRequests.d.ts.map +1 -1
  41. package/dist/plus/integrations/reads/pullRequests.js +1 -1
  42. package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
  43. package/dist/plus/integrations/reads/resolveRepository.d.ts +25 -0
  44. package/dist/plus/integrations/reads/resolveRepository.d.ts.map +1 -0
  45. package/dist/plus/integrations/reads/resolveRepository.js +235 -0
  46. package/dist/plus/integrations/reads/resolveRepository.js.map +1 -0
  47. package/dist/plus/integrations/reads/sweeps.d.ts +11 -0
  48. package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -0
  49. package/dist/plus/integrations/reads/sweeps.js +125 -0
  50. package/dist/plus/integrations/reads/sweeps.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/plus/integrations/errors.ts +6 -2
  53. package/src/plus/integrations/integrationService.ts +27 -801
  54. package/src/plus/integrations/models/gitHostIntegration.ts +71 -93
  55. package/src/plus/integrations/models/issuesIntegration.ts +5 -52
  56. package/src/plus/integrations/providers/azureDevOps.ts +1 -1
  57. package/src/plus/integrations/reads/broaden.ts +452 -0
  58. package/src/plus/integrations/reads/context.ts +24 -0
  59. package/src/plus/integrations/reads/hierarchy.ts +133 -153
  60. package/src/plus/integrations/reads/hierarchy.utils.ts +50 -1
  61. package/src/plus/integrations/reads/issueTracker.ts +15 -27
  62. package/src/plus/integrations/reads/issues.ts +4 -2
  63. package/src/plus/integrations/reads/pullRequests.ts +2 -9
  64. package/src/plus/integrations/reads/resolveRepository.ts +272 -0
  65. package/src/plus/integrations/reads/sweeps.ts +185 -0
@@ -0,0 +1,272 @@
1
+ import type { RemoteProviderConfig } from '../../../git/remotes/matcher.js';
2
+ import { createRemoteProviderMatcher } from '../../../git/remotes/matcher.js';
3
+ import { parseGitRemoteUrl } from '../../../git/utils/remote.utils.js';
4
+ import type { IntegrationIds } from '../constants.js';
5
+ import { GitCloudHostIntegrationId, GitSelfManagedHostIntegrationId } from '../constants.js';
6
+ import { AuthenticationError, RequestNotFoundError } from '../errors.js';
7
+ import { isIssuesIntegration } from '../models/issuesIntegration.js';
8
+ import { isAzureCloudDomain, isBitbucketCloudDomain, isGitHubDotCom, isGitLabDotCom } from '../providers/models.js';
9
+ import type { RepositoryIdentity, RepositoryResolution, ResolveRepositoryResult } from '../results.js';
10
+ import { toProviderWarning } from '../results.js';
11
+ import { hostFromDomain } from '../utils/domain.utils.js';
12
+ import {
13
+ getIntegrationIdForRemote,
14
+ isGitCloudHostIntegrationId,
15
+ isGitSelfManagedHostIntegrationId,
16
+ remoteProviderTypeForConfig,
17
+ remoteProviderTypeForIntegration,
18
+ } from '../utils/integration.utils.js';
19
+ import type { RepositoryResolutionContext } from './context.js';
20
+ import { noConnectionWarning } from './warnings.js';
21
+
22
+ /**
23
+ * Resolution of a remote URL to a provider repository identity — the facade's equivalent of `gk repo resolve`.
24
+ *
25
+ * Unlike every other read here, this one's input is REPOSITORY data, so most of the code below is about not
26
+ * letting that data choose the credentials: an explicit cloud `providerId` must agree with the remote's canonical
27
+ * host, and a self-managed host resolves only against a domain the user already authenticated. The
28
+ * `host-mismatch` returns are those guards, not edge cases — resolving `owner/repo` against the wrong account
29
+ * would hand back a confidently wrong identity and seed the domain-keyed instance cache from remote data.
30
+ */
31
+ export async function resolveRepository(
32
+ ctx: RepositoryResolutionContext,
33
+ options: {
34
+ providerId?: IntegrationIds;
35
+ remoteUrl: string;
36
+ host?: string;
37
+ connectionId?: string;
38
+ /**
39
+ * Explicit self-managed host domain used to select the integration instance. Used only when the requested
40
+ * connection has no configured domain; it must come from the trusted authentication configuration, not
41
+ * repository or remote data.
42
+ */
43
+ domain?: string;
44
+ },
45
+ ): Promise<ResolveRepositoryResult> {
46
+ const result = (status: RepositoryResolution['status']): ResolveRepositoryResult => ({
47
+ resolution: { status: status },
48
+ });
49
+
50
+ const [scheme, parsedDomain, path] = parseGitRemoteUrl(options.remoteUrl);
51
+ const parsedHost = hostFromDomain(parsedDomain);
52
+ const explicitHost = hostFromDomain(options.host);
53
+ if (parsedHost != null && explicitHost != null && parsedHost !== explicitHost) {
54
+ return result('host-mismatch');
55
+ }
56
+
57
+ const matcherDomain = parsedDomain || options.host || '';
58
+
59
+ // An explicit cloud provider must agree with the remote's canonical cloud host. Without this guard the
60
+ // synthetic matcher below can reinterpret, for example, a gitlab.com URL as GitHub and then resolve a
61
+ // homonymous owner/repo through the wrong account. Match once without caller configs/synthetic entries;
62
+ // Azure's matcher also normalizes ssh.dev.azure.com and vs-ssh.visualstudio.com to their web host.
63
+ if (options.providerId != null && isGitCloudHostIntegrationId(options.providerId)) {
64
+ const nativeProvider = createRemoteProviderMatcher([])(options.remoteUrl, matcherDomain, path, scheme);
65
+ const nativeId = nativeProvider != null ? getIntegrationIdForRemote(nativeProvider) : undefined;
66
+ const nativeDomain = nativeProvider?.domain ?? matcherDomain;
67
+ const canonicalHost =
68
+ options.providerId === GitCloudHostIntegrationId.GitHub
69
+ ? isGitHubDotCom(nativeDomain)
70
+ : options.providerId === GitCloudHostIntegrationId.GitLab
71
+ ? isGitLabDotCom(nativeDomain)
72
+ : options.providerId === GitCloudHostIntegrationId.Bitbucket
73
+ ? isBitbucketCloudDomain(nativeDomain)
74
+ : isAzureCloudDomain(nativeDomain);
75
+ if (nativeId !== options.providerId || !canonicalHost) return result('host-mismatch');
76
+ }
77
+
78
+ const provider = createRemoteProviderMatcher(buildMatcherConfigs(ctx, options, parsedDomain))(
79
+ options.remoteUrl,
80
+ matcherDomain,
81
+ path,
82
+ scheme,
83
+ );
84
+ if (provider == null) return result('invalid-remote-url');
85
+
86
+ let id = options.providerId ?? getIntegrationIdForRemote(provider);
87
+ // Custom Azure DevOps Server domains matched via getRemoteConfigs return undefined from
88
+ // getIntegrationIdForRemote because the provider is marked custom; map them to the server id so the
89
+ // project-scoped lookup uses that host's configured connection and API base URL.
90
+ if (id == null && provider.id === 'azure-devops' && provider.custom) {
91
+ id = GitSelfManagedHostIntegrationId.AzureDevOpsServer;
92
+ }
93
+ if (id == null) return result('unsupported-provider');
94
+
95
+ const owner = provider.owner;
96
+ const name = provider.repoName;
97
+ if (owner == null || name == null) return result('invalid-remote-url');
98
+
99
+ // On a self-managed host, resolve only against a TRUSTED host: the pinned connection's configured
100
+ // domain, the explicit `domain`, or — when neither was supplied — a configured host matching the
101
+ // remote's. That last case keeps `remoteUrl` (repository-supplied) out of the trusted path: it selects
102
+ // among hosts the user already authenticated and can never introduce a new one. Resolving `owner/repo`
103
+ // against some other host's account would, if that host has the same owner/repo, return a confidently
104
+ // wrong identity — and would seed the domain-keyed integration cache (never evicted before dispose) with
105
+ // an entry derived from repository data.
106
+ const urlHost = hostFromDomain(provider.domain);
107
+ // Normalize BOTH sides before comparing: a stored connection domain is usually a full URL
108
+ // (`https://git.example.com`) while `urlHost` is already a bare host, so a raw compare would fail on
109
+ // scheme/trailing-slash alone and wrongly reject a correctly-configured connection.
110
+ let trustedHost =
111
+ options.connectionId != null || options.domain != null
112
+ ? hostFromDomain(ctx.resolveDomainForRead(id, options.connectionId, options.domain))
113
+ : undefined;
114
+ if (isGitSelfManagedHostIntegrationId(id)) {
115
+ const configuredHosts = ctx.getConfigured(id).map(c => hostFromDomain(c.domain));
116
+ if (trustedHost == null && urlHost != null) {
117
+ trustedHost = configuredHosts.find(host => host === urlHost);
118
+ }
119
+ // A configured host that doesn't match the remote's is a genuine mismatch. With nothing configured
120
+ // there is no host to mismatch against — it's simply not connected, so fall through to the
121
+ // `unauthorized` path below (still WITHOUT resolving by the URL domain, so repo data can't seed the
122
+ // cache).
123
+ if (trustedHost == null && configuredHosts.length !== 0) return result('host-mismatch');
124
+ if (trustedHost != null && urlHost != null && trustedHost !== urlHost) return result('host-mismatch');
125
+ }
126
+
127
+ let integration;
128
+ try {
129
+ // Resolve the instance through the trusted target (as `getIntegrationForRead` does):
130
+ // `resolveReadSession` looks the session up against the instance's domain-scoped descriptor, so
131
+ // selecting the instance by the URL domain could miss the session and degrade to `no-connection`.
132
+ integration = isGitSelfManagedHostIntegrationId(id)
133
+ ? trustedHost != null
134
+ ? await ctx.getIntegrationForRead(id, options.connectionId, trustedHost)
135
+ : undefined
136
+ : await ctx.getIntegrationForDomain(id, provider.domain);
137
+ } catch {
138
+ integration = undefined;
139
+ }
140
+ if (integration == null) {
141
+ // Attach a warning, as the thrown-AuthenticationError branch below does: a consumer driving auth
142
+ // recovery off `warning.kind` would otherwise get nothing for an unresolvable target.
143
+ return {
144
+ resolution: {
145
+ status: 'unauthorized',
146
+ warning: noConnectionWarning(id, provider.domain, options.connectionId),
147
+ },
148
+ };
149
+ }
150
+ // Issue trackers have no `getRepo` client; a git host without `getRepoFn` leaves `getRepoInfo`
151
+ // undefined. Either way this provider can't resolve repositories.
152
+ if (isIssuesIntegration(integration) || integration.getRepoInfo == null) {
153
+ return result('unsupported-provider');
154
+ }
155
+
156
+ // Azure repos are org + project scoped; the remote provider exposes project as `providerDesc.repoDomain`.
157
+ const project = provider.id === 'azure-devops' ? provider.providerDesc?.repoDomain : undefined;
158
+ const domain = provider.domain;
159
+
160
+ // Azure's lookup is project-scoped and returns `undefined` without one, which the connection-gap branch
161
+ // below would misreport as `unauthorized` (driving a pointless reconnect). A remote URL that carries no
162
+ // project simply can't address an Azure repo.
163
+ if (provider.id === 'azure-devops' && project == null) return result('invalid-remote-url');
164
+
165
+ try {
166
+ const repo = await integration.getRepoInfo({
167
+ owner: owner,
168
+ name: name,
169
+ project: project,
170
+ connectionId: options.connectionId,
171
+ });
172
+ if (repo == null) {
173
+ // `getRepoInfo` returns undefined only when no session could be resolved (not connected, or the
174
+ // requested connection is gone) — a real 404 throws below. So this is a connection gap. Attach a
175
+ // warning so a consumer driving auth recovery off `warning.kind` sees the same signal it gets from
176
+ // the thrown-AuthenticationError branch below; without it, the most common unauthorized case is
177
+ // silent.
178
+ return {
179
+ resolution: {
180
+ status: 'unauthorized',
181
+ warning: noConnectionWarning(id, domain, options.connectionId),
182
+ },
183
+ };
184
+ }
185
+
186
+ // Prefer the provider's canonical namespace/name (GitHub's REST/GraphQL lookup follows the 301
187
+ // rename redirect, so a stale old name resolves to the new canonical identity), falling back to the
188
+ // parsed remote when the response omits them. `renamed` is a case-insensitive compare of input vs
189
+ // canonical, mirroring gkcli's `EqualFold`, so hosts that merely echo the input casing (e.g.
190
+ // Bitbucket Server/Azure) don't get spuriously flagged.
191
+ const canonicalOwner = repo.namespace || owner;
192
+ const canonicalName = repo.name || name;
193
+ const renamed =
194
+ canonicalOwner.toLowerCase() !== owner.toLowerCase() || canonicalName.toLowerCase() !== name.toLowerCase();
195
+
196
+ const identity: RepositoryIdentity = {
197
+ providerId: id,
198
+ domain: domain,
199
+ owner: canonicalOwner,
200
+ name: canonicalName,
201
+ project: project,
202
+ remoteUrl: options.remoteUrl,
203
+ renamed: renamed,
204
+ };
205
+ return { resolution: { status: 'resolved', identity: identity } };
206
+ } catch (ex) {
207
+ // Order matters: 404 throws RequestNotFoundError (not `undefined`), so check not-found before auth
208
+ // and before the generic 5xx/unknown bucket — never classify a 401/403 as not-found.
209
+ let resolution: RepositoryResolution;
210
+ if (ex instanceof RequestNotFoundError) {
211
+ resolution = { status: 'not-found' };
212
+ } else if (ex instanceof AuthenticationError) {
213
+ resolution = { status: 'unauthorized', warning: toProviderWarning(id, domain, options.connectionId, ex) };
214
+ } else {
215
+ resolution = { status: 'undetermined', warning: toProviderWarning(id, domain, options.connectionId, ex) };
216
+ }
217
+ return { resolution: resolution };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The remote-matcher configs for this resolution: the user's own host remote configs (self-managed/custom
223
+ * domains), plus a synthetic exact-domain entry for an explicit `providerId` + host so a custom domain still maps
224
+ * to the right provider for path parsing.
225
+ */
226
+ function buildMatcherConfigs(
227
+ ctx: RepositoryResolutionContext,
228
+ options: { providerId?: IntegrationIds; host?: string },
229
+ parsedDomain: string,
230
+ ): RemoteProviderConfig[] {
231
+ const configs: RemoteProviderConfig[] = [];
232
+ for (const cfg of ctx.getRemoteConfigs()) {
233
+ const type = remoteProviderTypeForConfig(cfg.type);
234
+ if (type == null) continue;
235
+
236
+ // Forward both domain- and regex-based custom remotes (carrying any protocol override), so a
237
+ // regex-configured host resolves instead of falling through to `unsupported`.
238
+ if (cfg.domain) {
239
+ configs.push({ type: type, domain: cfg.domain, protocol: cfg.protocol });
240
+ } else if (cfg.regex) {
241
+ configs.push({ type: type, regex: cfg.regex, protocol: cfg.protocol });
242
+ }
243
+ }
244
+ if (options.providerId == null) return configs;
245
+
246
+ const type = remoteProviderTypeForIntegration(options.providerId);
247
+ const domain = parsedDomain || options.host;
248
+ if (type == null || !domain) return configs;
249
+
250
+ // The synthetic exact-domain entry is unshifted to the front, so it wins the match over the user's own
251
+ // config for the same host. Carry that config's protocol override across (matched by domain or regex,
252
+ // mirroring `ignoreSSLErrors`) so a self-managed host configured for a custom protocol — e.g. plain
253
+ // `http` — isn't silently downgraded to the provider default here.
254
+ const lowerDomain = domain.toLowerCase();
255
+ const protocol = configs.find(c => {
256
+ if (c.type !== type) return false;
257
+ if (c.domain != null) return c.domain.toLowerCase() === lowerDomain;
258
+
259
+ // Truthy (not just non-null): an empty regex would compile to a match-everything pattern.
260
+ if (c.regex) {
261
+ try {
262
+ return new RegExp(c.regex, 'i').test(lowerDomain);
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ return false;
269
+ })?.protocol;
270
+ configs.unshift({ type: type, domain: domain, protocol: protocol });
271
+ return configs;
272
+ }
@@ -0,0 +1,185 @@
1
+ import type { PullRequestShape } from '../../../git/models/pullRequest.js';
2
+ import { mapBounded } from '../../../utils/promise.js';
3
+ import type { IntegrationIds } from '../constants.js';
4
+ import { providerFanOutConcurrency } from '../constants.js';
5
+ import type { ClosedPullRequestSweepOptions, PullRequestSweepOptions } from '../manager.js';
6
+ import { fromProviderPullRequest } from '../providers/models.js';
7
+ import type { ProviderSweepResult, ProviderWarning } from '../results.js';
8
+ import { appendDedupedWarning } from '../results.js';
9
+ import { isGitHostIntegration, isIssuesHostIntegrationId } from '../utils/integration.utils.js';
10
+ import type { ProviderReadContext } from './context.js';
11
+ import { drainPullRequests, getCurrentAccountId, resolvePullRequestSweepTargets } from './drains.js';
12
+ import { resolveAccountWidePullRequestFilters, resolvePullRequestFilters } from './filters.js';
13
+ import {
14
+ gitHostOnlySurfaceWarning,
15
+ noConnectionWarning,
16
+ unsupportedAccountWidePullRequestFiltersWarning,
17
+ unsupportedFiltersWarning,
18
+ } from './warnings.js';
19
+
20
+ /**
21
+ * The all-pages pull request reads: one drain per provider target, aggregated into a single sweep result.
22
+ *
23
+ * A sweep exposes no continuation of its own — it drains everything it can reach — so its whole contract lives in
24
+ * the completeness signals it returns: `page.allPages` asserts every page of every target was read,
25
+ * `page.truncated` says pages remain unread, and `failedProviderIds`/`incompleteProviderIds` separate a provider
26
+ * that produced nothing from one that produced a slice with a gap. That split is why the aggregation below can't
27
+ * be collapsed into the generic fan-out merge the hierarchy reads use.
28
+ */
29
+
30
+ /** One provider target's drained slice, before the cross-provider aggregation. */
31
+ interface SweepSlice {
32
+ items: PullRequestShape[];
33
+ warnings: ProviderWarning[];
34
+ fetchFailed: boolean;
35
+ truncated: boolean;
36
+ providerId: IntegrationIds;
37
+ failedProvider: boolean;
38
+ }
39
+
40
+ export async function sweepPullRequests(
41
+ ctx: ProviderReadContext,
42
+ options?: PullRequestSweepOptions,
43
+ ): Promise<ProviderSweepResult<PullRequestShape>> {
44
+ const { targets, attributeUnavailableProviders } = resolvePullRequestSweepTargets(options);
45
+ const maxPages = options?.maxPages ?? 100;
46
+ const repos = options?.repos ?? [];
47
+
48
+ const results = await mapBounded(targets, providerFanOutConcurrency, async target => {
49
+ const { providerId: id, connectionId, domain: requestedDomain } = target;
50
+ /** A target that never reached a drain: the provider itself failed, so its slice is empty and attributed. */
51
+ const rejectedTarget = (warnings: ProviderWarning[]): SweepSlice => ({
52
+ items: [],
53
+ warnings: warnings,
54
+ fetchFailed: true,
55
+ truncated: false,
56
+ providerId: id,
57
+ failedProvider: true,
58
+ });
59
+
60
+ if (isIssuesHostIntegrationId(id)) {
61
+ return rejectedTarget([
62
+ gitHostOnlySurfaceWarning(id, requestedDomain, connectionId, 'pull request sweeps'),
63
+ ]);
64
+ }
65
+
66
+ const integration = await ctx.getIntegrationForRead(id, connectionId, requestedDomain);
67
+ if (integration == null) {
68
+ // A requested connection that can't be resolved is a broken connection — surface it as a
69
+ // warning + fetchFailed rather than dropping the provider's slice silently.
70
+ const early = ctx.earlyReturnConnectionWarnings(id, connectionId, requestedDomain);
71
+ if (early.warnings.length === 0 && !attributeUnavailableProviders) return undefined;
72
+
73
+ return rejectedTarget(
74
+ early.warnings.length !== 0 ? early.warnings : [noConnectionWarning(id, requestedDomain, connectionId)],
75
+ );
76
+ }
77
+ if (!isGitHostIntegration(integration)) {
78
+ return rejectedTarget([
79
+ gitHostOnlySurfaceWarning(id, requestedDomain, connectionId, 'pull request sweeps'),
80
+ ]);
81
+ }
82
+
83
+ await ctx.forceRefreshIfRequested(integration, options?.forceSync, connectionId);
84
+
85
+ const domain = ctx.domainForRead(integration, id, connectionId, requestedDomain);
86
+ const accountWide = repos.length === 0;
87
+ const requestedFilters = target.filters ?? options?.filters;
88
+ const resolved = accountWide
89
+ ? resolveAccountWidePullRequestFilters(id, requestedFilters)
90
+ : resolvePullRequestFilters(id, requestedFilters);
91
+ if (resolved.unsupported) {
92
+ return rejectedTarget([
93
+ accountWide
94
+ ? unsupportedAccountWidePullRequestFiltersWarning(id, domain, connectionId, requestedFilters ?? [])
95
+ : unsupportedFiltersWarning(id, domain, connectionId),
96
+ ]);
97
+ }
98
+
99
+ const drain = await drainPullRequests(
100
+ integration,
101
+ id,
102
+ domain,
103
+ repos,
104
+ options?.states,
105
+ resolved.filters,
106
+ accountWide ? (options?.includeReviewRequested ?? false) : false,
107
+ connectionId,
108
+ maxPages,
109
+ attributeUnavailableProviders,
110
+ );
111
+ const currentAccountId = drain.items.some(pr => pr.author != null)
112
+ ? await getCurrentAccountId(integration, connectionId)
113
+ : undefined;
114
+ // Normalize the raw provider-apis PRs to the GitLens-owned shape here, where the per-provider
115
+ // `integration` (the mapper's provider reference) is in scope; the aggregation below only sees drains.
116
+ return {
117
+ ...drain,
118
+ items: drain.items.map(pr =>
119
+ fromProviderPullRequest(pr, integration, { currentAccountId: currentAccountId }),
120
+ ),
121
+ providerId: id,
122
+ };
123
+ });
124
+
125
+ const items: PullRequestShape[] = [];
126
+ const warnings: ProviderWarning[] = [];
127
+ const failedProviderIds = new Set<IntegrationIds>();
128
+ const incompleteProviderIds = new Set<IntegrationIds>();
129
+ let fetchFailed = false;
130
+ let truncated = false;
131
+ for (const drain of results) {
132
+ if (drain == null) {
133
+ continue;
134
+ }
135
+
136
+ items.push(...drain.items);
137
+ for (const w of drain.warnings) {
138
+ appendDedupedWarning(warnings, w);
139
+ }
140
+ if (drain.fetchFailed) {
141
+ fetchFailed = true;
142
+ }
143
+ if (drain.failedProvider) {
144
+ failedProviderIds.add(drain.providerId);
145
+ } else if (drain.fetchFailed || drain.truncated) {
146
+ incompleteProviderIds.add(drain.providerId);
147
+ }
148
+ if (drain.truncated) {
149
+ truncated = true;
150
+ }
151
+ }
152
+
153
+ return {
154
+ items: items,
155
+ warnings: warnings,
156
+ // `allPages` asserts completeness — it must be false when any provider truncated (a single-page
157
+ // account-wide read that couldn't confirm it drained everything) OR a drain aborted on a read
158
+ // failure (its slice is incomplete). Either way the sweep did not read every page.
159
+ page: {
160
+ currentPage: 1,
161
+ itemsPerPage: items.length,
162
+ allPages: !truncated && !fetchFailed,
163
+ truncated: truncated || undefined,
164
+ },
165
+ // A sweep drains every page itself and exposes no cursor to resume — so `hasMore` must be false even
166
+ // when the read was incomplete. Terminal incompleteness is expressed through `page.truncated` +
167
+ // `allPages: false` + warnings; setting `hasMore: true` here would make a consumer that drains while
168
+ // `hasMore` re-run the identical sweep forever with no cursor to advance.
169
+ hasMore: false,
170
+ fetchFailed: fetchFailed || undefined,
171
+ failedProviderIds: [...failedProviderIds],
172
+ incompleteProviderIds: [...incompleteProviderIds],
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Closed/merged counterpart of {@link sweepPullRequests}, feeding Kepler's Kanban "done" column. Applies
178
+ * the native cross-provider state filter (`Closed` + `Merged`) so it works beyond GitHub.
179
+ */
180
+ export function sweepClosedPullRequests(
181
+ ctx: ProviderReadContext,
182
+ options?: ClosedPullRequestSweepOptions,
183
+ ): Promise<ProviderSweepResult<PullRequestShape>> {
184
+ return sweepPullRequests(ctx, { ...options, states: ['closed', 'merged'] });
185
+ }