@gitkraken/core-gitlens 0.5.100 → 0.5.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 (61) hide show
  1. package/CHANGELOG.md +19 -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 +21 -1
  20. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  21. package/dist/plus/integrations/collectionMetadata.js +82 -6
  22. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  23. package/dist/plus/integrations/integrationService.d.ts +3 -3
  24. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  25. package/dist/plus/integrations/integrationService.js +19 -3
  26. package/dist/plus/integrations/integrationService.js.map +1 -1
  27. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  28. package/dist/plus/integrations/models/integration.js +3 -2
  29. package/dist/plus/integrations/models/integration.js.map +1 -1
  30. package/dist/plus/integrations/providers/models.d.ts +1 -0
  31. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  32. package/dist/plus/integrations/providers/models.js.map +1 -1
  33. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  34. package/dist/plus/integrations/providers/providersApi.js +4 -2
  35. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  36. package/dist/plus/integrations/providers/trello.d.ts +2 -0
  37. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  38. package/dist/plus/integrations/providers/trello.js +1 -1
  39. package/dist/plus/integrations/providers/trello.js.map +1 -1
  40. package/dist/plus/integrations/providers/utils/providerPaging.d.ts +4 -2
  41. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  42. package/dist/plus/integrations/providers/utils/providerPaging.js +34 -22
  43. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  44. package/dist/plus/integrations/reads/warnings.d.ts +4 -2
  45. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  46. package/dist/plus/integrations/reads/warnings.js +4 -2
  47. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/git/models/issue.ts +3 -2
  50. package/src/git/utils/issue.utils.ts +9 -6
  51. package/src/git/utils/remote.utils.ts +6 -0
  52. package/src/plus/git-github/api/github.ts +19 -9
  53. package/src/plus/git-github/models.ts +27 -31
  54. package/src/plus/integrations/collectionMetadata.ts +97 -7
  55. package/src/plus/integrations/integrationService.ts +37 -9
  56. package/src/plus/integrations/models/integration.ts +3 -2
  57. package/src/plus/integrations/providers/models.ts +4 -1
  58. package/src/plus/integrations/providers/providersApi.ts +6 -10
  59. package/src/plus/integrations/providers/trello.ts +3 -2
  60. package/src/plus/integrations/providers/utils/providerPaging.ts +46 -24
  61. package/src/plus/integrations/reads/warnings.ts +4 -2
@@ -2,7 +2,7 @@ import type { Endpoints } from '@octokit/types';
2
2
  import { GitFileIndexStatus } from '../../git/models/fileStatus.js';
3
3
  import type { IssueLabel } from '../../git/models/issue.js';
4
4
  import { Issue, RepositoryAccessLevel } from '../../git/models/issue.js';
5
- import type { PullRequestState } from '../../git/models/pullRequest.js';
5
+ import type { PullRequestMember, PullRequestState } from '../../git/models/pullRequest.js';
6
6
  import {
7
7
  PullRequest,
8
8
  PullRequestMergeableState,
@@ -131,8 +131,9 @@ export type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED';
131
131
  export type GitHubIssueOrPullRequestState = GitHubIssueState | GitHubPullRequestState;
132
132
 
133
133
  export interface GitHubPullRequestLite extends Omit<GitHubIssueOrPullRequest, '__typename'> {
134
- author: GitHubMember;
135
- body: string;
134
+ /** `Actor` is nullable in GitHub's schema — `null` once the author's account is deleted */
135
+ author: GitHubMember | null;
136
+ body: string | null;
136
137
 
137
138
  baseRefName: string;
138
139
  baseRefOid: string;
@@ -167,7 +168,8 @@ export interface GitHubPullRequestLite extends Omit<GitHubIssueOrPullRequest, '_
167
168
  }
168
169
 
169
170
  export interface GitHubIssue extends Omit<GitHubIssueOrPullRequest, '__typename'> {
170
- author: GitHubMember;
171
+ /** `Actor` is nullable in GitHub's schema — `null` once the author's account is deleted */
172
+ author: GitHubMember | null;
171
173
  assignees: { nodes: GitHubMember[] };
172
174
  comments?: {
173
175
  totalCount: number;
@@ -203,7 +205,7 @@ export interface GitHubPullRequest extends GitHubPullRequestLite {
203
205
  reviewDecision: GitHubPullRequestReviewDecision;
204
206
  latestReviews: {
205
207
  nodes: {
206
- author: GitHubMember;
208
+ author: GitHubMember | null;
207
209
  state: GitHubPullRequestReviewState;
208
210
  }[];
209
211
  };
@@ -234,15 +236,17 @@ export type GitHubViewerPermission =
234
236
  | 'READ' // Can read and clone this repository. Can also open and comment on issues and pull requests
235
237
  | 'NONE';
236
238
 
239
+ /** `ghost` is how github.com renders an actor whose account was deleted */
240
+ function fromGitHubMemberOrGhost(member: GitHubMember | null | undefined): PullRequestMember {
241
+ if (member == null) return { id: 'ghost', name: 'ghost' };
242
+
243
+ return { id: member.login, name: member.login, avatarUrl: member.avatarUrl, url: member.url };
244
+ }
245
+
237
246
  export function fromGitHubPullRequestLite(pr: GitHubPullRequestLite, provider: Provider): PullRequest {
238
247
  return new PullRequest(
239
248
  provider,
240
- {
241
- id: pr.author.login,
242
- name: pr.author.login,
243
- avatarUrl: pr.author.avatarUrl,
244
- url: pr.author.url,
245
- },
249
+ fromGitHubMemberOrGhost(pr.author),
246
250
  String(pr.number),
247
251
  pr.id,
248
252
  pr.title,
@@ -296,7 +300,7 @@ export function fromGitHubPullRequestLite(pr: GitHubPullRequestLite, provider: P
296
300
  undefined, // statusCheckRollupState
297
301
  undefined, // project
298
302
  undefined, // version
299
- pr.body,
303
+ pr.body ?? undefined,
300
304
  );
301
305
  }
302
306
 
@@ -399,12 +403,7 @@ export function fromGitHubPullRequestStatusCheckRollupState(
399
403
  export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider): PullRequest {
400
404
  return new PullRequest(
401
405
  provider,
402
- {
403
- id: pr.author.login,
404
- name: pr.author.login,
405
- avatarUrl: pr.author.avatarUrl,
406
- url: pr.author.url,
407
- },
406
+ fromGitHubMemberOrGhost(pr.author),
408
407
  String(pr.number),
409
408
  pr.id,
410
409
  pr.title,
@@ -469,12 +468,7 @@ export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider)
469
468
  )
470
469
  .filter(<T>(r?: T): r is T => Boolean(r)),
471
470
  pr.latestReviews.nodes.map(r => ({
472
- reviewer: {
473
- id: r.author.login,
474
- name: r.author.login,
475
- avatarUrl: r.author.avatarUrl,
476
- url: r.author.url,
477
- },
471
+ reviewer: fromGitHubMemberOrGhost(r.author),
478
472
  state: fromGitHubPullRequestReviewState(r.state),
479
473
  })),
480
474
  pr.assignees.nodes.map(r => ({
@@ -486,7 +480,7 @@ export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider)
486
480
  fromGitHubPullRequestStatusCheckRollupState(pr.commits.nodes?.[0]?.commit.statusCheckRollup?.state),
487
481
  undefined, // project
488
482
  undefined, // version
489
- pr.body,
483
+ pr.body ?? undefined,
490
484
  );
491
485
  }
492
486
 
@@ -506,12 +500,14 @@ export function fromGitHubIssue(value: GitHubIssue, provider: Provider): Issue {
506
500
  new Date(value.updatedAt),
507
501
  value.closed,
508
502
  fromGitHubIssueOrPullRequestState(value.state),
509
- {
510
- id: value.author.login,
511
- name: value.author.login,
512
- avatarUrl: value.author.avatarUrl,
513
- url: value.author.url,
514
- },
503
+ value.author == null
504
+ ? undefined
505
+ : {
506
+ id: value.author.login,
507
+ name: value.author.login,
508
+ avatarUrl: value.author.avatarUrl,
509
+ url: value.author.url,
510
+ },
515
511
  value.assignees.nodes.map(assignee => ({
516
512
  id: assignee.login,
517
513
  name: assignee.login,
@@ -1,4 +1,9 @@
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';
@@ -68,8 +73,21 @@ function toCollectionFailureWarningKind(failure: CollectionScopeFailure): Provid
68
73
  }
69
74
  }
70
75
 
71
- function collectionFailureMessage(failure: CollectionScopeFailure): string {
72
- const scope = failure.scope;
76
+ /**
77
+ * A scope's identity, as the stable string that keys it.
78
+ *
79
+ * The dedup keys in `providerPaging.ts` build on this, so failures and omissions can never disagree about
80
+ * what "the same scope" means. `providerId` is included: the same repository ID under two providers is two
81
+ * scopes.
82
+ */
83
+ export function collectionScopeKey(scope: CollectionScope | undefined): string {
84
+ return [scope?.providerId ?? '', scope?.resourceId ?? '', scope?.projectId ?? '', scope?.repositoryId ?? ''].join(
85
+ ' ',
86
+ );
87
+ }
88
+
89
+ /** ` (resource r, project p, repository o/n)` for the scope IDs present; empty when the scope names none. */
90
+ function collectionScopeText(scope: CollectionScope | undefined): string {
73
91
  const parts: string[] = [];
74
92
  if (scope?.resourceId != null) {
75
93
  parts.push(`resource ${scope.resourceId}`);
@@ -81,9 +99,64 @@ function collectionFailureMessage(failure: CollectionScopeFailure): string {
81
99
  parts.push(`repository ${scope.repositoryId}`);
82
100
  }
83
101
 
84
- const scopeText = parts.length ? ` (${parts.join(', ')})` : '';
102
+ return parts.length ? ` (${parts.join(', ')})` : '';
103
+ }
104
+
105
+ function collectionFailureMessage(failure: CollectionScopeFailure): string {
85
106
  const detail = failure.message != null ? `: ${failure.message}` : '';
86
- return `Failed to read ${failure.kind} scope${scopeText}${detail}`;
107
+ return `Failed to read ${failure.kind} scope${collectionScopeText(failure.scope)}${detail}`;
108
+ }
109
+
110
+ /**
111
+ * Explains one omission in the consumer's terms — what was left out and, where the SDK reports it, how much.
112
+ *
113
+ * An omission is a completeness fact, never a failure: the read succeeded and the provider (or the SDK's own
114
+ * recovery budget) is what withheld results, so retrying the same request cannot recover them. That is why
115
+ * these never contribute to `fetchFailed`.
116
+ */
117
+ function collectionOmissionMessage(omission: CollectionOmission): string {
118
+ const scopeText = collectionScopeText(omission.scope);
119
+ switch (omission.kind) {
120
+ case 'provider-limit':
121
+ // `totalCount` is the only figure saying how much was withheld; both fields are optional, so degrade
122
+ // through the shapes the SDK can actually emit rather than printing `undefined`.
123
+ if (omission.totalCount != null && omission.limit != null) {
124
+ return `Search${scopeText} matched ${omission.totalCount} results, but the provider exposes at most ${omission.limit}`;
125
+ }
126
+ if (omission.limit != null) {
127
+ return `Search${scopeText} exceeded the provider limit of ${omission.limit} results`;
128
+ }
129
+ return `Search${scopeText} exceeded the provider's result limit`;
130
+ case 'recovery-budget':
131
+ return `Stopped recovering omitted results${scopeText} after reaching the request budget${
132
+ omission.limit != null ? ` of ${omission.limit} requests` : ''
133
+ }`;
134
+ case 'pagination-incomplete':
135
+ return `More results are available${scopeText} than this read returned`;
136
+ default:
137
+ // `CollectionOmissionKind` is a closed union, so this is unreachable today. Kept as a compile-time
138
+ // guard rather than a silent fallback: an SDK bump that adds a kind breaks the build here instead
139
+ // of quietly degrading it to a vague message.
140
+ omission.kind satisfies never;
141
+ return `Some results were omitted${scopeText}; the read is incomplete`;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Whether SDK metadata describes a read that may be missing results.
147
+ *
148
+ * A reported omission counts on its own rather than deferring to `completeness`. The SDK does couple the
149
+ * two — every site that emits an omission degrades completeness in the same call — so today the omission
150
+ * clause changes no outcome. It is defensive: the two facts arrive as independent fields, and a read that
151
+ * names what it left out must not be publishable as whole if a future producer sets only one of them.
152
+ *
153
+ * This is the single definition of "may be missing results", shared with `getPagedResult`, so one metadata
154
+ * object cannot be truncated at one layer and complete at another.
155
+ */
156
+ export function isIncompleteCollection(metadata: CollectionMetadata | undefined): boolean {
157
+ if (metadata == null) return false;
158
+
159
+ return metadata.completeness !== 'complete' || (metadata.omissions?.length ?? 0) > 0;
87
160
  }
88
161
 
89
162
  /** Converts internal SDK collection metadata into neutral provider facade signals. */
@@ -109,8 +182,25 @@ export function assessCollectionMetadata(
109
182
  });
110
183
  }
111
184
 
112
- const incomplete = metadata.completeness !== 'complete';
113
- if (incomplete && failures.length === 0) {
185
+ // Omissions explain WHY a read is incomplete when nothing failed — a provider cap, an exhausted recovery
186
+ // budget, an undrained scope. They classify as `other`, never `auth`, and are deliberately excluded from
187
+ // `fetchFailed` below: the request succeeded, so a retry would return the same truncated set.
188
+ const omissions = metadata.omissions ?? [];
189
+ for (const omission of omissions) {
190
+ appendDedupedWarning(warnings, {
191
+ providerId: providerId,
192
+ domain: domain,
193
+ connectionId: connectionId,
194
+ message: collectionOmissionMessage(omission),
195
+ kind: 'other',
196
+ isAuth: false,
197
+ });
198
+ }
199
+
200
+ const incomplete = isIncompleteCollection(metadata);
201
+ // Only fall back to the generic message when nothing more specific was reported; an omission already
202
+ // explains the incompleteness in the consumer's terms, so adding this on top would be noise.
203
+ if (incomplete && failures.length === 0 && omissions.length === 0) {
114
204
  appendDedupedWarning(warnings, {
115
205
  providerId: providerId,
116
206
  domain: domain,
@@ -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,12 @@
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';
3
- import { toCollectionScopeFailure } from '../../collectionMetadata.js';
8
+ import { uniqueBy } from '../../../../utils/iterable.js';
9
+ import { collectionScopeKey, toCollectionScopeFailure } from '../../collectionMetadata.js';
4
10
  import type { ProviderApiPagedResult, ProviderHierarchyResult } from '../models.js';
5
11
 
6
12
  /**
@@ -42,21 +48,29 @@ const completenessRank: Record<CollectionCompleteness, number> = { partial: 2, u
42
48
 
43
49
  /** A stable key for deduplicating structurally-identical scope failures accumulated across drained pages. */
44
50
  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(' ');
51
+ return [failure.kind, collectionScopeKey(failure.scope), failure.message ?? ''].join(' ');
52
+ }
53
+
54
+ /**
55
+ * A stable key for collapsing omissions accumulated across drained pages, matching the SDK's own
56
+ * `dedupeOmissions`: kind plus scope IDs, deliberately WITHOUT `limit`/`totalCount`.
57
+ *
58
+ * Those counts are a re-measurement, not an identity. GitHub recomputes the match total on every request, so
59
+ * one repository drained over several pages reports the same cap with a drifting total; keying on the total
60
+ * would emit a near-identical warning per page ("matched 1393…", "matched 1402…") that
61
+ * `appendDedupedWarning` cannot collapse, since the messages genuinely differ. Independently-scoped omissions
62
+ * still stay distinct, which is the case that carries information.
63
+ */
64
+ function collectionOmissionKey(omission: CollectionOmission): string {
65
+ return [omission.kind, collectionScopeKey(omission.scope)].join(' ');
54
66
  }
55
67
 
56
68
  /**
57
69
  * 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.
70
+ * failures are deduplicated by kind, scope IDs, and message, and omissions are collapsed per kind and scope
71
+ * keeping the highest reported total ({@link collectionOmissionKey}). Both preserve first-reported order.
72
+ * Returns `undefined` when no page supplied metadata, so metadata-free providers and test doubles keep
73
+ * behaving as before.
60
74
  */
61
75
  export function mergeCollectionMetadata(
62
76
  base: CollectionMetadata | undefined,
@@ -70,17 +84,25 @@ export function mergeCollectionMetadata(
70
84
  ? next.completeness
71
85
  : base.completeness;
72
86
 
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 } : {}) };
87
+ // First occurrence wins: a repeated failure carries no new information, since the message is part of its key.
88
+ const failures = [
89
+ ...uniqueBy([...(base.failures ?? []), ...(next.failures ?? [])], collectionFailureKey, original => original),
90
+ ];
91
+ // Highest total wins, so re-measuring one cap across pages collapses to a single omission holding the
92
+ // largest figure reported for it — the same rule the SDK applies in `createCollectionMetadata`.
93
+ const omissions = [
94
+ ...uniqueBy(
95
+ [...(base.omissions ?? []), ...(next.omissions ?? [])],
96
+ collectionOmissionKey,
97
+ (original, current) => ((current.totalCount ?? -1) > (original.totalCount ?? -1) ? current : undefined),
98
+ ),
99
+ ];
100
+
101
+ return {
102
+ completeness: completeness,
103
+ ...(failures.length ? { failures: failures } : {}),
104
+ ...(omissions.length ? { omissions: omissions } : {}),
105
+ };
84
106
  }
85
107
 
86
108
  /**
@@ -15,8 +15,10 @@ import type { ProviderWarning } from '../results.js';
15
15
  /**
16
16
  * The single builder for a provider-neutral, non-auth warning: an unsupported capability, a
17
17
  * contradictory/inexpressible request, or a read that couldn't confirm completeness. Every `kind: 'other'`
18
- * warning the facade emits goes through here (directly, or via one of the named builders below that pin a
19
- * recurring message), so the discriminant is assigned in exactly one place.
18
+ * warning the facade raises on its own terms goes through here (directly, or via one of the named builders
19
+ * below that pin a recurring message), so the discriminant is assigned in one place. The warnings mapped from
20
+ * SDK collection metadata are the documented exception — `collectionMetadata.ts` builds those from the
21
+ * structured failure/omission it is given.
20
22
  *
21
23
  * The kinds that carry a programmatic remedy — `auth`, `rate-limit`, `not-found`, `no-connection` — are derived
22
24
  * from the caught error's type instead and never come from here; see `ProviderWarningKind` and