@gitkraken/core-gitlens 0.5.100 → 0.5.102

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 (87) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/git/models/issue.d.ts +4 -3
  3. package/dist/git/models/issue.d.ts.map +1 -1
  4. package/dist/git/models/issue.js.map +1 -1
  5. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  6. package/dist/git/utils/issue.utils.js +8 -6
  7. package/dist/git/utils/issue.utils.js.map +1 -1
  8. package/dist/git/utils/remote.utils.d.ts +3 -0
  9. package/dist/git/utils/remote.utils.d.ts.map +1 -1
  10. package/dist/git/utils/remote.utils.js +5 -0
  11. package/dist/git/utils/remote.utils.js.map +1 -1
  12. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  13. package/dist/plus/git-github/api/github.js +18 -5
  14. package/dist/plus/git-github/api/github.js.map +1 -1
  15. package/dist/plus/git-github/models.d.ts +6 -4
  16. package/dist/plus/git-github/models.d.ts.map +1 -1
  17. package/dist/plus/git-github/models.js +19 -26
  18. package/dist/plus/git-github/models.js.map +1 -1
  19. package/dist/plus/integrations/collectionMetadata.d.ts +12 -0
  20. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  21. package/dist/plus/integrations/collectionMetadata.js +111 -6
  22. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  23. package/dist/plus/integrations/index.d.ts +1 -1
  24. package/dist/plus/integrations/index.d.ts.map +1 -1
  25. package/dist/plus/integrations/index.js.map +1 -1
  26. package/dist/plus/integrations/integrationService.d.ts +3 -3
  27. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  28. package/dist/plus/integrations/integrationService.js +19 -3
  29. package/dist/plus/integrations/integrationService.js.map +1 -1
  30. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  31. package/dist/plus/integrations/models/integration.js +3 -2
  32. package/dist/plus/integrations/models/integration.js.map +1 -1
  33. package/dist/plus/integrations/providers/models.d.ts +1 -0
  34. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  35. package/dist/plus/integrations/providers/models.js.map +1 -1
  36. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  37. package/dist/plus/integrations/providers/providersApi.js +4 -2
  38. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  39. package/dist/plus/integrations/providers/trello.d.ts +2 -0
  40. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  41. package/dist/plus/integrations/providers/trello.js +1 -1
  42. package/dist/plus/integrations/providers/trello.js.map +1 -1
  43. package/dist/plus/integrations/providers/utils/providerPaging.d.ts +4 -2
  44. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  45. package/dist/plus/integrations/providers/utils/providerPaging.js +34 -21
  46. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  47. package/dist/plus/integrations/reads/drains.d.ts.map +1 -1
  48. package/dist/plus/integrations/reads/drains.js +59 -20
  49. package/dist/plus/integrations/reads/drains.js.map +1 -1
  50. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  51. package/dist/plus/integrations/reads/issueTracker.js +8 -2
  52. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  53. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  54. package/dist/plus/integrations/reads/issues.js +13 -3
  55. package/dist/plus/integrations/reads/issues.js.map +1 -1
  56. package/dist/plus/integrations/reads/pullRequests.d.ts.map +1 -1
  57. package/dist/plus/integrations/reads/pullRequests.js +7 -1
  58. package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
  59. package/dist/plus/integrations/reads/warnings.d.ts +41 -6
  60. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  61. package/dist/plus/integrations/reads/warnings.js +54 -7
  62. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  63. package/dist/plus/integrations/results.d.ts +115 -1
  64. package/dist/plus/integrations/results.d.ts.map +1 -1
  65. package/dist/plus/integrations/results.js +78 -3
  66. package/dist/plus/integrations/results.js.map +1 -1
  67. package/docs/integrations.md +81 -7
  68. package/package.json +2 -2
  69. package/src/git/models/issue.ts +3 -2
  70. package/src/git/utils/issue.utils.ts +9 -6
  71. package/src/git/utils/remote.utils.ts +6 -0
  72. package/src/plus/git-github/api/github.ts +19 -9
  73. package/src/plus/git-github/models.ts +27 -31
  74. package/src/plus/integrations/collectionMetadata.ts +125 -8
  75. package/src/plus/integrations/index.ts +4 -0
  76. package/src/plus/integrations/integrationService.ts +37 -9
  77. package/src/plus/integrations/models/integration.ts +3 -2
  78. package/src/plus/integrations/providers/models.ts +4 -1
  79. package/src/plus/integrations/providers/providersApi.ts +6 -10
  80. package/src/plus/integrations/providers/trello.ts +3 -2
  81. package/src/plus/integrations/providers/utils/providerPaging.ts +46 -23
  82. package/src/plus/integrations/reads/drains.ts +84 -20
  83. package/src/plus/integrations/reads/issueTracker.ts +8 -2
  84. package/src/plus/integrations/reads/issues.ts +21 -4
  85. package/src/plus/integrations/reads/pullRequests.ts +15 -1
  86. package/src/plus/integrations/reads/warnings.ts +82 -12
  87. package/src/plus/integrations/results.ts +173 -4
@@ -1,8 +1,13 @@
1
- import type { CollectionMetadata, CollectionScopeFailure } from '@gitkraken/provider-apis';
1
+ import type {
2
+ CollectionMetadata,
3
+ CollectionOmission,
4
+ CollectionScope,
5
+ CollectionScopeFailure,
6
+ } from '@gitkraken/provider-apis';
2
7
  import { AuthenticationError, RequestNotFoundError, RequestRateLimitError } from '../../git/errors.js';
3
8
  import type { IntegrationIds } from './constants.js';
4
9
  import { isRateLimitResponse } from './errors.js';
5
- import type { ProviderWarning } from './results.js';
10
+ import type { ProviderWarning, ProviderWarningOmission } from './results.js';
6
11
  import { appendDedupedWarning } from './results.js';
7
12
 
8
13
  /**
@@ -68,8 +73,8 @@ function toCollectionFailureWarningKind(failure: CollectionScopeFailure): Provid
68
73
  }
69
74
  }
70
75
 
71
- function collectionFailureMessage(failure: CollectionScopeFailure): string {
72
- const scope = failure.scope;
76
+ /** ` (resource r, project p, repository o/n)` for the scope IDs present; empty when the scope names none. */
77
+ function collectionScopeText(scope: CollectionScope | undefined): string {
73
78
  const parts: string[] = [];
74
79
  if (scope?.resourceId != null) {
75
80
  parts.push(`resource ${scope.resourceId}`);
@@ -81,9 +86,98 @@ function collectionFailureMessage(failure: CollectionScopeFailure): string {
81
86
  parts.push(`repository ${scope.repositoryId}`);
82
87
  }
83
88
 
84
- const scopeText = parts.length ? ` (${parts.join(', ')})` : '';
89
+ return parts.length ? ` (${parts.join(', ')})` : '';
90
+ }
91
+
92
+ function collectionFailureMessage(failure: CollectionScopeFailure): string {
85
93
  const detail = failure.message != null ? `: ${failure.message}` : '';
86
- return `Failed to read ${failure.kind} scope${scopeText}${detail}`;
94
+ return `Failed to read ${failure.kind} scope${collectionScopeText(failure.scope)}${detail}`;
95
+ }
96
+
97
+ /**
98
+ * Explains one omission in the consumer's terms — what was left out and, where the SDK reports it, how much.
99
+ *
100
+ * An omission is a completeness fact, never a failure: the read succeeded, and the provider (or the SDK's own
101
+ * recovery budget) is what withheld results. That is why these never contribute to `fetchFailed`. Whether a
102
+ * retry would recover anything is a separate question this layer cannot answer — see the `recovery` note on
103
+ * {@link toProviderWarningOmission}.
104
+ */
105
+ function collectionOmissionMessage(omission: CollectionOmission): string {
106
+ const scopeText = collectionScopeText(omission.scope);
107
+ switch (omission.kind) {
108
+ case 'provider-limit':
109
+ // `totalCount` is the only figure saying how much was withheld; both fields are optional, so degrade
110
+ // through the shapes the SDK can actually emit rather than printing `undefined`.
111
+ if (omission.totalCount != null && omission.limit != null) {
112
+ return `Search${scopeText} matched ${omission.totalCount} results, but the provider exposes at most ${omission.limit}`;
113
+ }
114
+ if (omission.limit != null) {
115
+ return `Search${scopeText} exceeded the provider limit of ${omission.limit} results`;
116
+ }
117
+ return `Search${scopeText} exceeded the provider's result limit`;
118
+ case 'recovery-budget':
119
+ return `Stopped recovering omitted results${scopeText} after reaching the request budget${
120
+ omission.limit != null ? ` of ${omission.limit} requests` : ''
121
+ }`;
122
+ case 'pagination-incomplete':
123
+ return `More results are available${scopeText} than this read returned`;
124
+ default:
125
+ // `CollectionOmissionKind` is a closed union, so this is unreachable today. Kept as a compile-time
126
+ // guard rather than a silent fallback: an SDK bump that adds a kind breaks the build here instead
127
+ // of quietly degrading it to a vague message.
128
+ omission.kind satisfies never;
129
+ return `Some results were omitted${scopeText}; the read is incomplete`;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Forwards an SDK omission as the structured signal consumers read instead of parsing {@link
135
+ * collectionOmissionMessage}'s prose.
136
+ *
137
+ * `results.ts` re-spells `CollectionOmissionKind` rather than importing it, so the published warning surface
138
+ * carries no `@gitkraken/provider-apis` types (see the export block in `index.ts`). The return type is what
139
+ * keeps the two unions honest: an SDK bump that adds a member fails to compile here, alongside
140
+ * `collectionOmissionMessage`'s `satisfies never`.
141
+ *
142
+ * `scope` is copied because the SDK's own object is retained and re-merged across drained pages
143
+ * (`providerPaging.ts`), and this one crosses the package boundary.
144
+ *
145
+ * `recovery` is always `'none'`, deliberately, and a scoped `pagination-incomplete` is why it looks wrong: the
146
+ * SDK emits that one shape from situations with opposite remedies and says so itself — "Either the read
147
+ * deliberately took one page per scope, or the provider advertised another page it gave no way to reach." Its
148
+ * `collectAcrossScopes` producers (Azure, Bitbucket, Bitbucket Server) are the first, its `drainAcrossScopes`
149
+ * producers (GitLab, Jira) the second, and those report an omission only when a cursor STALLED — so re-reading
150
+ * that scope stalls at the identical page. Nothing in `CollectionOmission` separates the two, so claiming
151
+ * recoverability would ship the dead-end button `recovery` exists to prevent. Making the recoverable case
152
+ * claimable needs provider-apis to say whether a scope was drained or merely sampled: a fact to forward, not
153
+ * to guess here.
154
+ */
155
+ function toProviderWarningOmission(omission: CollectionOmission): ProviderWarningOmission {
156
+ return {
157
+ kind: omission.kind,
158
+ recovery: 'none',
159
+ ...(omission.limit != null ? { limit: omission.limit } : {}),
160
+ // The SDK types this `number | null | undefined`; normalize to one absence for consumers.
161
+ ...(omission.totalCount != null ? { totalCount: omission.totalCount } : {}),
162
+ ...(omission.scope != null ? { scope: { ...omission.scope } } : {}),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Whether SDK metadata describes a read that may be missing results.
168
+ *
169
+ * A reported omission counts on its own rather than deferring to `completeness`. The SDK does couple the
170
+ * two — every site that emits an omission degrades completeness in the same call — so today the omission
171
+ * clause changes no outcome. It is defensive: the two facts arrive as independent fields, and a read that
172
+ * names what it left out must not be publishable as whole if a future producer sets only one of them.
173
+ *
174
+ * This is the single definition of "may be missing results", shared with `getPagedResult`, so one metadata
175
+ * object cannot be truncated at one layer and complete at another.
176
+ */
177
+ export function isIncompleteCollection(metadata: CollectionMetadata | undefined): boolean {
178
+ if (metadata == null) return false;
179
+
180
+ return metadata.completeness !== 'complete' || (metadata.omissions?.length ?? 0) > 0;
87
181
  }
88
182
 
89
183
  /** Converts internal SDK collection metadata into neutral provider facade signals. */
@@ -109,8 +203,31 @@ export function assessCollectionMetadata(
109
203
  });
110
204
  }
111
205
 
112
- const incomplete = metadata.completeness !== 'complete';
113
- if (incomplete && failures.length === 0) {
206
+ // Omissions explain WHY a read is incomplete when nothing failed — a provider cap, an exhausted recovery
207
+ // budget, an undrained scope. They classify as `other`, never `auth`, and are deliberately excluded from
208
+ // `fetchFailed` below: the request itself succeeded. `omission` carries that same fact structurally, so a
209
+ // consumer can act on it without parsing the message.
210
+ const omissions = metadata.omissions ?? [];
211
+ for (const omission of omissions) {
212
+ appendDedupedWarning(warnings, {
213
+ providerId: providerId,
214
+ domain: domain,
215
+ connectionId: connectionId,
216
+ message: collectionOmissionMessage(omission),
217
+ kind: 'other',
218
+ isAuth: false,
219
+ omission: toProviderWarningOmission(omission),
220
+ });
221
+ }
222
+
223
+ const incomplete = isIncompleteCollection(metadata);
224
+ // Only fall back to the generic message when nothing more specific was reported; an omission already
225
+ // explains the incompleteness in the consumer's terms, so adding this on top would be noise.
226
+ //
227
+ // This one deliberately carries no `omission`: it fires precisely when the SDK reported incompleteness
228
+ // WITHOUT saying what was left out, so there is no structured fact to forward and synthesizing one would
229
+ // assert a specificity this layer does not have.
230
+ if (incomplete && failures.length === 0 && omissions.length === 0) {
114
231
  appendDedupedWarning(warnings, {
115
232
  providerId: providerId,
116
233
  domain: domain,
@@ -202,6 +202,10 @@ export type {
202
202
  ProviderSweepResult,
203
203
  ProviderWarning,
204
204
  ProviderWarningKind,
205
+ ProviderWarningOmission,
206
+ ProviderWarningOmissionKind,
207
+ ProviderWarningOmissionRecovery,
208
+ ProviderWarningOmissionScope,
205
209
  ProviderOrganization,
206
210
  ProviderRepositoryShape,
207
211
  RepositoryIdentity,
@@ -555,7 +555,7 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
555
555
  async getMyIssues(
556
556
  integrationIds?: (GitCloudHostIntegrationId | IssuesCloudHostIntegrationId | GitSelfManagedHostIntegrationId)[],
557
557
  options?: { openRepositoriesOnly?: boolean; cancellation?: AbortSignal },
558
- ): Promise<IssueShape[] | undefined> {
558
+ ): Promise<IntegrationResult<IssueShape[] | undefined>> {
559
559
  const integrations: Map<Integration, ResourceDescriptor[] | undefined> = new Map();
560
560
  const hostingIntegrationIds = integrationIds?.filter(
561
561
  id => id in GitCloudHostIntegrationId || id in GitSelfManagedHostIntegrationId,
@@ -623,26 +623,54 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
623
623
  private async getMyIssuesCore(
624
624
  integrations: Map<Integration, ResourceDescriptor[] | undefined>,
625
625
  cancellation?: AbortSignal,
626
- ): Promise<IssueShape[] | undefined> {
627
- const promises: Promise<IssueShape[] | undefined>[] = [];
626
+ ): Promise<IntegrationResult<IssueShape[] | undefined>> {
627
+ const start = performance.now();
628
+
629
+ const promises: Promise<IntegrationResult<IssueShape[] | undefined>>[] = [];
628
630
  for (const [integration, repos] of integrations) {
629
631
  if (integration == null) continue;
630
632
 
631
- promises.push(integration.searchMyIssues(repos, cancellation));
633
+ promises.push(integration.searchMyIssuesResult(repos, cancellation));
632
634
  }
633
635
 
634
636
  const results = await Promise.allSettled(promises);
635
- return [...flatten(filterMap(results, r => (r.status === 'fulfilled' ? r.value : undefined)))];
637
+ const successfulResults = [
638
+ ...flatten(
639
+ filterMap(results, r =>
640
+ r.status === 'fulfilled' && r.value?.value != null ? r.value.value : undefined,
641
+ ),
642
+ ),
643
+ ];
644
+ const errors = [
645
+ ...filterMap(results, r =>
646
+ r.status === 'fulfilled' && r.value?.error != null ? r.value.error : undefined,
647
+ ),
648
+ ];
649
+
650
+ const error =
651
+ errors.length === 0
652
+ ? undefined
653
+ : errors.length === 1
654
+ ? errors[0]
655
+ : new AggregateError(errors, 'Failed to get some issues');
656
+
657
+ return {
658
+ value: successfulResults,
659
+ error: error,
660
+ duration: performance.now() - start,
661
+ };
636
662
  }
637
663
 
638
- async getMyIssuesForRemotes(remote: GitRemote): Promise<IssueShape[] | undefined>;
639
- async getMyIssuesForRemotes(remotes: GitRemote[]): Promise<IssueShape[] | undefined>;
664
+ async getMyIssuesForRemotes(remote: GitRemote): Promise<IntegrationResult<IssueShape[] | undefined>>;
665
+ async getMyIssuesForRemotes(remotes: GitRemote[]): Promise<IntegrationResult<IssueShape[] | undefined>>;
640
666
  @trace({
641
667
  args: (remoteOrRemotes: GitRemote | GitRemote[]) => ({
642
668
  remoteOrRemotes: Array.isArray(remoteOrRemotes) ? remoteOrRemotes.map(rp => rp.name) : remoteOrRemotes.name,
643
669
  }),
644
670
  })
645
- async getMyIssuesForRemotes(remoteOrRemotes: GitRemote | GitRemote[]): Promise<IssueShape[] | undefined> {
671
+ async getMyIssuesForRemotes(
672
+ remoteOrRemotes: GitRemote | GitRemote[],
673
+ ): Promise<IntegrationResult<IssueShape[] | undefined>> {
646
674
  if (!Array.isArray(remoteOrRemotes)) {
647
675
  remoteOrRemotes = [remoteOrRemotes];
648
676
  }
@@ -653,7 +681,7 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
653
681
  if (remote?.provider == null) return undefined;
654
682
 
655
683
  const integration = await this.getByRemote(remote);
656
- return integration?.searchMyIssues(remote.provider.repoDesc);
684
+ return integration?.searchMyIssuesResult(remote.provider.repoDesc);
657
685
  }
658
686
 
659
687
  const integrations = new Map<GitHostIntegration, ResourceDescriptor[]>();
@@ -737,6 +737,7 @@ export abstract class IntegrationBase<
737
737
  const session = await this.resolveReadSession(connectionId, scope);
738
738
  if (session == null) return undefined;
739
739
 
740
+ const start = performance.now();
740
741
  try {
741
742
  const issues = await this.searchProviderMyIssues(
742
743
  session,
@@ -744,10 +745,10 @@ export abstract class IntegrationBase<
744
745
  cancellation,
745
746
  );
746
747
  this.resetRequestExceptionCount('searchMyIssues');
747
- return { value: issues };
748
+ return { value: issues, duration: performance.now() - start };
748
749
  } catch (ex) {
749
750
  this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
750
- return { error: toError(ex) };
751
+ return { error: toError(ex), duration: performance.now() - start };
751
752
  }
752
753
  }
753
754
 
@@ -470,7 +470,10 @@ export type GetIssueFn = (
470
470
  export type GetIssuesForReposFn = (
471
471
  input: (GetIssuesForReposInput | GetIssuesForRepoIdsInput) & PagingInput,
472
472
  options?: EnterpriseOptions,
473
- ) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>;
473
+ // GitHub's multi-repo issue search reports its 1,000-result cap — and, when it recovers past it by
474
+ // partitioning, what that recovery could not reach — through `metadata`. Documented on the contract for
475
+ // the reader; `getPagedResult` accepts and forwards `metadata` from any provider fn regardless.
476
+ ) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo; metadata?: CollectionMetadata }>;
474
477
 
475
478
  export type GetIssuesForCurrentUserInput = PagingInput & {
476
479
  // GitLab's account-wide REST read (`GET /issues`): `scope` controls breadth ('assigned_to_me' vs 'all') and
@@ -13,6 +13,7 @@ import type { PagedResult } from '../../../utils/paging.js';
13
13
  import type { IntegrationAuthenticationService } from '../authentication/integrationAuthenticationService.js';
14
14
  import type { TokenOptInfo, TokenWithInfo } from '../authentication/models.js';
15
15
  import { toTokenWithInfo } from '../authentication/models.js';
16
+ import { isIncompleteCollection } from '../collectionMetadata.js';
16
17
  import type { IntegrationIds } from '../constants.js';
17
18
  import {
18
19
  GitCloudHostIntegrationId,
@@ -32,7 +33,6 @@ import type {
32
33
  GetIssueFn,
33
34
  GetIssuesForReposFn,
34
35
  GetIssuesOptions,
35
- GetPullRequestsForRepoFn,
36
36
  GetPullRequestsForReposFn,
37
37
  GetPullRequestsForUserFn,
38
38
  GetPullRequestsForUserOptions,
@@ -275,9 +275,7 @@ export class ProvidersApi {
275
275
  getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind(
276
276
  providerApis.gitlab,
277
277
  ) as GetPullRequestsForReposFn,
278
- getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind(
279
- providerApis.gitlab,
280
- ) as GetPullRequestsForRepoFn,
278
+ getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind(providerApis.gitlab),
281
279
  getPullRequestsForUserFn: providerApis.gitlab.getPullRequestsAssociatedWithUser.bind(
282
280
  providerApis.gitlab,
283
281
  ) as GetPullRequestsForUserFn,
@@ -302,9 +300,7 @@ export class ProvidersApi {
302
300
  getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind(
303
301
  providerApis.gitlab,
304
302
  ) as GetPullRequestsForReposFn,
305
- getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind(
306
- providerApis.gitlab,
307
- ) as GetPullRequestsForRepoFn,
303
+ getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind(providerApis.gitlab),
308
304
  getPullRequestsForUserFn: providerApis.gitlab.getPullRequestsAssociatedWithUser.bind(
309
305
  providerApis.gitlab,
310
306
  ) as GetPullRequestsForUserFn,
@@ -681,9 +677,9 @@ export class ProvidersApi {
681
677
  // SDK collection completeness is independent from provider-native pagination: a result can expose a
682
678
  // real next page (`more`) and still have a failed sibling scope (`partial`/`unknown`). Surface the
683
679
  // latter as `truncated` so consumers treat the page as incomplete. Absent metadata (old providers,
684
- // test doubles) leaves `truncated` unset for backward compatibility.
685
- const truncated =
686
- normalizedMetadata != null && normalizedMetadata.completeness !== 'complete' ? true : undefined;
680
+ // test doubles) leaves `truncated` unset for backward compatibility. Shares the incompleteness
681
+ // predicate with the facade assessment so one metadata object can't be truncated there and whole here.
682
+ const truncated = isIncompleteCollection(normalizedMetadata) ? true : undefined;
687
683
 
688
684
  return {
689
685
  values: result.data,
@@ -1,3 +1,4 @@
1
+ import type { CollectionMetadata } from '@gitkraken/provider-apis';
1
2
  import type { Account } from '../../../git/models/author.js';
2
3
  import type { Issue, IssueShape } from '../../../git/models/issue.js';
3
4
  import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest.js';
@@ -103,7 +104,7 @@ export class TrelloIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
103
104
  session: ProviderAuthenticationSession,
104
105
  project: ResourceDescriptor,
105
106
  options?: { user?: string; filters?: IssueFilter[] },
106
- ): Promise<{ values: IssueShape[]; truncated: boolean } | undefined> {
107
+ ): Promise<{ values: IssueShape[]; truncated: boolean; metadata?: CollectionMetadata } | undefined> {
107
108
  // A non-issue descriptor genuinely has nothing to read (empty), but a missing app key is a broken read.
108
109
  if (!isIssueResourceDescriptor(project)) return undefined;
109
110
 
@@ -139,7 +140,7 @@ export class TrelloIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
139
140
  // never a cursor. Surface that as terminal truncation; there is no next page to fetch, so retrying the
140
141
  // same read cannot recover the omitted cards (D11).
141
142
  const truncated = result.metadata != null && result.metadata.completeness !== 'complete';
142
- return { values: values, truncated: truncated };
143
+ return { values: values, truncated: truncated, metadata: result.metadata };
143
144
  }
144
145
 
145
146
  protected override searchProviderMyIssues(
@@ -1,6 +1,13 @@
1
- import type { CollectionCompleteness, CollectionMetadata, CollectionScopeFailure } from '@gitkraken/provider-apis';
1
+ import type {
2
+ CollectionCompleteness,
3
+ CollectionMetadata,
4
+ CollectionOmission,
5
+ CollectionScopeFailure,
6
+ } from '@gitkraken/provider-apis';
2
7
  import { isCancellationError } from '../../../../utils/cancellation.js';
8
+ import { uniqueBy } from '../../../../utils/iterable.js';
3
9
  import { toCollectionScopeFailure } from '../../collectionMetadata.js';
10
+ import { collectionScopeKey } from '../../results.js';
4
11
  import type { ProviderApiPagedResult, ProviderHierarchyResult } from '../models.js';
5
12
 
6
13
  /**
@@ -42,21 +49,29 @@ const completenessRank: Record<CollectionCompleteness, number> = { partial: 2, u
42
49
 
43
50
  /** A stable key for deduplicating structurally-identical scope failures accumulated across drained pages. */
44
51
  function collectionFailureKey(failure: CollectionScopeFailure): string {
45
- const scope = failure.scope;
46
- return [
47
- failure.kind,
48
- scope?.providerId ?? '',
49
- scope?.resourceId ?? '',
50
- scope?.projectId ?? '',
51
- scope?.repositoryId ?? '',
52
- failure.message ?? '',
53
- ].join(' ');
52
+ return [failure.kind, collectionScopeKey(failure.scope), failure.message ?? ''].join(' ');
53
+ }
54
+
55
+ /**
56
+ * A stable key for collapsing omissions accumulated across drained pages, matching the SDK's own
57
+ * `dedupeOmissions`: kind plus scope IDs, deliberately WITHOUT `limit`/`totalCount`.
58
+ *
59
+ * Those counts are a re-measurement, not an identity. GitHub recomputes the match total on every request, so
60
+ * one repository drained over several pages reports the same cap with a drifting total; keying on the total
61
+ * would emit a near-identical warning per page ("matched 1393…", "matched 1402…") that
62
+ * `appendDedupedWarning` cannot collapse, since the messages genuinely differ. Independently-scoped omissions
63
+ * still stay distinct, which is the case that carries information.
64
+ */
65
+ function collectionOmissionKey(omission: CollectionOmission): string {
66
+ return [omission.kind, collectionScopeKey(omission.scope)].join(' ');
54
67
  }
55
68
 
56
69
  /**
57
70
  * Merges SDK collection metadata across drained pages. Completeness follows {@link completenessRank};
58
- * failures are concatenated and deduplicated by kind, scope IDs, and message. Returns `undefined` when no
59
- * page supplied metadata, so metadata-free providers and test doubles keep behaving as before.
71
+ * failures are deduplicated by kind, scope IDs, and message, and omissions are collapsed per kind and scope
72
+ * keeping the highest reported total ({@link collectionOmissionKey}). Both preserve first-reported order.
73
+ * Returns `undefined` when no page supplied metadata, so metadata-free providers and test doubles keep
74
+ * behaving as before.
60
75
  */
61
76
  export function mergeCollectionMetadata(
62
77
  base: CollectionMetadata | undefined,
@@ -70,17 +85,25 @@ export function mergeCollectionMetadata(
70
85
  ? next.completeness
71
86
  : base.completeness;
72
87
 
73
- const failures: CollectionScopeFailure[] = [];
74
- const seen = new Set<string>();
75
- for (const failure of [...(base.failures ?? []), ...(next.failures ?? [])]) {
76
- const key = collectionFailureKey(failure);
77
- if (seen.has(key)) continue;
78
-
79
- seen.add(key);
80
- failures.push(failure);
81
- }
82
-
83
- return { completeness: completeness, ...(failures.length ? { failures: failures } : {}) };
88
+ // First occurrence wins: a repeated failure carries no new information, since the message is part of its key.
89
+ const failures = [
90
+ ...uniqueBy([...(base.failures ?? []), ...(next.failures ?? [])], collectionFailureKey, original => original),
91
+ ];
92
+ // Highest total wins, so re-measuring one cap across pages collapses to a single omission holding the
93
+ // largest figure reported for it — the same rule the SDK applies in `createCollectionMetadata`.
94
+ const omissions = [
95
+ ...uniqueBy(
96
+ [...(base.omissions ?? []), ...(next.omissions ?? [])],
97
+ collectionOmissionKey,
98
+ (original, current) => ((current.totalCount ?? -1) > (original.totalCount ?? -1) ? current : undefined),
99
+ ),
100
+ ];
101
+
102
+ return {
103
+ completeness: completeness,
104
+ ...(failures.length ? { failures: failures } : {}),
105
+ ...(omissions.length ? { omissions: omissions } : {}),
106
+ };
84
107
  }
85
108
 
86
109
  /**
@@ -9,7 +9,7 @@ import type { PullRequestFilter } from '../providerFilters.js';
9
9
  import type { ProviderPullRequest, ProviderReposInput, ProviderRepository } from '../providers/models.js';
10
10
  import { getProviderPullRequestIdentity } from '../providers/models.js';
11
11
  import type { ProviderWarning } from '../results.js';
12
- import { appendDedupedWarning, toProviderWarning } from '../results.js';
12
+ import { appendDedupedWarning, reconcileOmissionsWithFailure, toProviderWarning } from '../results.js';
13
13
  import { isIssuesHostIntegrationId } from '../utils/integration.utils.js';
14
14
  import { noConnectionWarning, truncationWarning } from './warnings.js';
15
15
 
@@ -90,10 +90,18 @@ export async function drainPullRequests(
90
90
  // this through the terminal returns instead of resetting it to false at the last page.
91
91
  let fetchFailed = false;
92
92
  let truncated = false;
93
+ // A page the provider capped or couldn't vouch for. Decides the CAUSE the terminal warning reports: a cap
94
+ // outranks a budget stop, because raising a budget cannot un-cap a page.
95
+ let providerTruncated = false;
96
+ // The subset of that which no other warning already explains — decides whether this drain raises one of
97
+ // its own on an otherwise clean exit.
98
+ let unexplainedTruncation = false;
93
99
 
94
100
  // With no repos this is an account-wide "my PRs" sweep. The repo-scoped core rejects an empty `repos`
95
101
  // input, so read the provider-native account-wide core instead.
96
102
  const accountWide = repos.length === 0;
103
+ /** Every cursor already followed, so a provider that cycles them can't keep the drain walking in circles. */
104
+ const seenCursors = new Set<string>();
97
105
 
98
106
  for (;;) {
99
107
  page++;
@@ -130,10 +138,23 @@ export async function drainPullRequests(
130
138
  appendDedupedWarning(warnings, noConnectionWarning(id, domain, connectionId));
131
139
  }
132
140
  // `warning` set → a hard read failure (incomplete items); otherwise not connected / no session.
141
+ const failed = fetchFailed || warning != null || unavailable;
142
+ // A cap seen on an earlier page still left results out, so it is still worth saying — but as part
143
+ // of a read that failed, never as an omission. (`failed` is always true here when anything was
144
+ // latched: reaching this exit past page 1 means a later page was lost.)
145
+ if (unexplainedTruncation) {
146
+ appendDedupedWarning(
147
+ warnings,
148
+ truncationWarning(id, domain, connectionId, 'Pull request', failed ? 'interrupted' : 'exhausted'),
149
+ );
150
+ }
151
+ // An earlier page may already have emitted an omission before this one died; it asserts the read
152
+ // succeeded, which is no longer true.
153
+ reconcileOmissionsWithFailure(warnings, failed);
133
154
  return {
134
155
  items: items,
135
156
  warnings: warnings,
136
- fetchFailed: fetchFailed || warning != null || unavailable,
157
+ fetchFailed: failed,
137
158
  truncated: truncated || sessionLostAfterProgress,
138
159
  // Only a top-level first-page rejection means the provider itself failed. A later-page or
139
160
  // per-scope failure still yielded a usable provider slice and stays represented separately.
@@ -171,14 +192,31 @@ export async function drainPullRequests(
171
192
  value.paging?.truncated === true ||
172
193
  assessment.truncated;
173
194
  truncated = truncated || pageTruncated;
174
- if (pageTruncated && !assessment.truncated) {
175
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Pull request'));
176
- }
195
+ // A page the provider itself capped, or one whose completeness it couldn't confirm. Latched from ANY
196
+ // source, SDK metadata included: no budget of ours un-caps a page, so a later `maxPages` hit must not
197
+ // claim raising it would help.
198
+ providerTruncated = providerTruncated || pageTruncated;
199
+ // Whether this drain owes its OWN warning for that is a separate question — `mergeAssessmentInto` has
200
+ // already appended one when the fact came from SDK metadata, and repeating it would be noise.
201
+ unexplainedTruncation = unexplainedTruncation || (pageTruncated && !assessment.truncated);
177
202
 
178
203
  if (!(value.paging?.more ?? false)) {
179
204
  // A read that can't confirm completeness (single-page provider reads with no `hasNextPage`)
180
205
  // sets `paging.truncated`; propagate it (and any top-level `truncated` and SDK incompleteness)
181
206
  // so the sweep doesn't claim an all-pages result.
207
+ if (unexplainedTruncation) {
208
+ appendDedupedWarning(
209
+ warnings,
210
+ truncationWarning(
211
+ id,
212
+ domain,
213
+ connectionId,
214
+ 'Pull request',
215
+ fetchFailed ? 'interrupted' : 'exhausted',
216
+ ),
217
+ );
218
+ }
219
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
182
220
  return {
183
221
  items: items,
184
222
  warnings: warnings,
@@ -187,21 +225,34 @@ export async function drainPullRequests(
187
225
  failedProvider: false,
188
226
  };
189
227
  }
190
- if (page >= maxPages) {
191
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Pull request'));
192
- return {
193
- items: items,
194
- warnings: warnings,
195
- fetchFailed: fetchFailed,
196
- truncated: true,
197
- failedProvider: false,
198
- };
199
- }
200
228
 
229
+ // Resolve the continuation BEFORE deciding why the drain stops. `page-budget` claims the missing items
230
+ // are reachable, which is only true with a usable cursor in hand — and a provider can report another
231
+ // page while handing back none (Bitbucket Server does, when it omits `nextPageStart`). Checking the
232
+ // budget first would label that unreachable tail as merely unfetched, so raising `maxPages` would
233
+ // return the identical set.
201
234
  const nextCursor = value.paging?.cursor;
202
- if (nextCursor == null || nextCursor === '{}') {
203
- // Provider says there is more but didn't return a usable cursor; stop rather than refetch the same page.
204
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Pull request'));
235
+ // A cursor already used isn't a continuation either — following it refetches a page we have, and a
236
+ // provider that cycles (A→B→A) would otherwise burn the whole budget and then be reported as merely
237
+ // out of budget. Tracked as a SET rather than compared one-back, matching the SDK's own `followCursors`:
238
+ // `drainToRequestedPage` and `collectProviderPagedResult` compare only the previous cursor, which a
239
+ // cycle slips past. That is tolerable there and not here, because only this drain reports `page-budget`.
240
+ const continuable = nextCursor != null && nextCursor !== '{}' && !seenCursors.has(nextCursor);
241
+ if (!continuable || page >= maxPages) {
242
+ // `providerTruncated` outranks the budget: a page the provider capped stays capped however many
243
+ // pages we are allowed to read, so promising `page-budget` on top of it would be a load-more that
244
+ // cannot deliver the capped part.
245
+ appendDedupedWarning(
246
+ warnings,
247
+ truncationWarning(
248
+ id,
249
+ domain,
250
+ connectionId,
251
+ 'Pull request',
252
+ fetchFailed ? 'interrupted' : continuable && !providerTruncated ? 'page-budget' : 'exhausted',
253
+ ),
254
+ );
255
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
205
256
  return {
206
257
  items: items,
207
258
  warnings: warnings,
@@ -211,6 +262,7 @@ export async function drainPullRequests(
211
262
  };
212
263
  }
213
264
 
265
+ seenCursors.add(nextCursor);
214
266
  cursor = nextCursor;
215
267
  }
216
268
  }
@@ -262,12 +314,20 @@ export async function drainRepositories(
262
314
  if (value == null) {
263
315
  const interruptedAfterProgress = page > 1;
264
316
  if (interruptedAfterProgress && warning == null) {
265
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Repository'));
317
+ // Not a backstop: the read was cut short mid-drain, which is why it also sets `fetchFailed`
318
+ // below. A retry may complete it, so this must not claim the succeeded-but-capped omission.
319
+ appendDedupedWarning(
320
+ warnings,
321
+ truncationWarning(id, domain, connectionId, 'Repository', 'interrupted'),
322
+ );
266
323
  }
324
+ const failed = fetchFailed || warning != null || interruptedAfterProgress;
325
+ // An SDK omission from an earlier page asserts the read succeeded; this one didn't.
326
+ reconcileOmissionsWithFailure(warnings, failed);
267
327
  return {
268
328
  repos: repos,
269
329
  warnings: warnings,
270
- fetchFailed: fetchFailed || warning != null || interruptedAfterProgress,
330
+ fetchFailed: failed,
271
331
  truncated: truncated || interruptedAfterProgress,
272
332
  };
273
333
  }
@@ -277,6 +337,7 @@ export async function drainRepositories(
277
337
  fetchFailed = fetchFailed || assessment.fetchFailed;
278
338
  truncated = truncated || value.truncated === true || value.paging?.truncated === true || assessment.truncated;
279
339
  if (!(value.paging?.more ?? false)) {
340
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
280
341
  return {
281
342
  repos: repos,
282
343
  warnings: warnings,
@@ -284,13 +345,16 @@ export async function drainRepositories(
284
345
  truncated: truncated,
285
346
  };
286
347
  }
348
+
287
349
  if (page >= maxPages) {
350
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
288
351
  return { repos: repos, warnings: warnings, fetchFailed: fetchFailed, truncated: true };
289
352
  }
290
353
 
291
354
  const nextCursor = value.paging?.cursor;
292
355
  if (nextCursor == null || nextCursor === '{}') {
293
356
  // Provider says there is more but didn't return a usable cursor; stop rather than refetch the same page.
357
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
294
358
  return { repos: repos, warnings: warnings, fetchFailed: fetchFailed, truncated: true };
295
359
  }
296
360