@gitkraken/core-gitlens 0.4.101 → 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 (121) hide show
  1. package/CHANGELOG.md +29 -6
  2. package/dist/git/models/graph.d.ts +63 -33
  3. package/dist/git/models/graph.d.ts.map +1 -1
  4. package/dist/git/models/graph.js.map +1 -1
  5. package/dist/git/models/graphSession.d.ts +3 -84
  6. package/dist/git/models/graphSession.d.ts.map +1 -1
  7. package/dist/git/models/graphSession.js +1 -6
  8. package/dist/git/models/graphSession.js.map +1 -1
  9. package/dist/git/models/issue.d.ts +4 -3
  10. package/dist/git/models/issue.d.ts.map +1 -1
  11. package/dist/git/models/issue.js.map +1 -1
  12. package/dist/git/providers/graph.d.ts +1 -15
  13. package/dist/git/providers/graph.d.ts.map +1 -1
  14. package/dist/git/providers/pausedOperations.d.ts +2 -0
  15. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  16. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  17. package/dist/git/utils/issue.utils.js +8 -6
  18. package/dist/git/utils/issue.utils.js.map +1 -1
  19. package/dist/git/utils/reachability.utils.d.ts +1 -1
  20. package/dist/git/utils/reachability.utils.js +1 -1
  21. package/dist/git/utils/remote.utils.d.ts +3 -0
  22. package/dist/git/utils/remote.utils.d.ts.map +1 -1
  23. package/dist/git/utils/remote.utils.js +5 -0
  24. package/dist/git/utils/remote.utils.js.map +1 -1
  25. package/dist/git-cli/providers/graph.d.ts +1 -3
  26. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  27. package/dist/git-cli/providers/graph.js +37 -214
  28. package/dist/git-cli/providers/graph.js.map +1 -1
  29. package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
  30. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  31. package/dist/git-cli/providers/pausedOperations.js +21 -1
  32. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  33. package/dist/git-cli/providers/staging.d.ts.map +1 -1
  34. package/dist/git-cli/providers/staging.js +10 -1
  35. package/dist/git-cli/providers/staging.js.map +1 -1
  36. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  37. package/dist/plus/git-github/api/github.js +18 -5
  38. package/dist/plus/git-github/api/github.js.map +1 -1
  39. package/dist/plus/git-github/models.d.ts +6 -4
  40. package/dist/plus/git-github/models.d.ts.map +1 -1
  41. package/dist/plus/git-github/models.js +19 -26
  42. package/dist/plus/git-github/models.js.map +1 -1
  43. package/dist/plus/git-github/providers/github/graph.d.ts +1 -3
  44. package/dist/plus/git-github/providers/github/graph.d.ts.map +1 -1
  45. package/dist/plus/git-github/providers/github/graph.js +8 -10
  46. package/dist/plus/git-github/providers/github/graph.js.map +1 -1
  47. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts +38 -48
  48. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts.map +1 -1
  49. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +22 -5
  50. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
  51. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts +39 -0
  52. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts.map +1 -0
  53. package/dist/plus/integrations/authentication/rejectedTokenTracker.js +53 -0
  54. package/dist/plus/integrations/authentication/rejectedTokenTracker.js.map +1 -0
  55. package/dist/plus/integrations/collectionMetadata.d.ts +21 -1
  56. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  57. package/dist/plus/integrations/collectionMetadata.js +82 -6
  58. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  59. package/dist/plus/integrations/integrationService.d.ts +3 -3
  60. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  61. package/dist/plus/integrations/integrationService.js +19 -3
  62. package/dist/plus/integrations/integrationService.js.map +1 -1
  63. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  64. package/dist/plus/integrations/models/gitHostIntegration.js +19 -6
  65. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  66. package/dist/plus/integrations/models/integration.d.ts +20 -0
  67. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  68. package/dist/plus/integrations/models/integration.js +64 -6
  69. package/dist/plus/integrations/models/integration.js.map +1 -1
  70. package/dist/plus/integrations/models/issuesIntegration.js +5 -5
  71. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  72. package/dist/plus/integrations/providers/models.d.ts +1 -0
  73. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  74. package/dist/plus/integrations/providers/models.js.map +1 -1
  75. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  76. package/dist/plus/integrations/providers/providersApi.js +4 -2
  77. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  78. package/dist/plus/integrations/providers/trello.d.ts +2 -0
  79. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  80. package/dist/plus/integrations/providers/trello.js +1 -1
  81. package/dist/plus/integrations/providers/trello.js.map +1 -1
  82. package/dist/plus/integrations/providers/utils/providerPaging.d.ts +4 -2
  83. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  84. package/dist/plus/integrations/providers/utils/providerPaging.js +34 -22
  85. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  86. package/dist/plus/integrations/reads/warnings.d.ts +4 -2
  87. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  88. package/dist/plus/integrations/reads/warnings.js +4 -2
  89. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  90. package/dist/utils/date.d.ts +1 -1
  91. package/dist/utils/date.d.ts.map +1 -1
  92. package/dist/utils/date.js +5 -3
  93. package/dist/utils/date.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/git/models/graph.ts +59 -41
  96. package/src/git/models/graphSession.ts +3 -106
  97. package/src/git/models/issue.ts +3 -2
  98. package/src/git/providers/graph.ts +1 -15
  99. package/src/git/providers/pausedOperations.ts +8 -1
  100. package/src/git/utils/issue.utils.ts +9 -6
  101. package/src/git/utils/reachability.utils.ts +1 -1
  102. package/src/git/utils/remote.utils.ts +6 -0
  103. package/src/git-cli/providers/graph.ts +37 -250
  104. package/src/git-cli/providers/pausedOperations.ts +29 -2
  105. package/src/git-cli/providers/staging.ts +8 -1
  106. package/src/plus/git-github/api/github.ts +19 -9
  107. package/src/plus/git-github/models.ts +27 -31
  108. package/src/plus/git-github/providers/github/graph.ts +8 -15
  109. package/src/plus/integrations/authentication/integrationAuthenticationProvider.ts +62 -40
  110. package/src/plus/integrations/authentication/rejectedTokenTracker.ts +56 -0
  111. package/src/plus/integrations/collectionMetadata.ts +97 -7
  112. package/src/plus/integrations/integrationService.ts +37 -9
  113. package/src/plus/integrations/models/gitHostIntegration.ts +19 -6
  114. package/src/plus/integrations/models/integration.ts +71 -7
  115. package/src/plus/integrations/models/issuesIntegration.ts +5 -5
  116. package/src/plus/integrations/providers/models.ts +4 -1
  117. package/src/plus/integrations/providers/providersApi.ts +6 -10
  118. package/src/plus/integrations/providers/trello.ts +3 -2
  119. package/src/plus/integrations/providers/utils/providerPaging.ts +46 -24
  120. package/src/plus/integrations/reads/warnings.ts +4 -2
  121. package/src/utils/date.ts +6 -3
@@ -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[]>();
@@ -373,7 +373,10 @@ export abstract class GitHostIntegration<
373
373
  this.resetRequestExceptionCount('getOrganizationsForUser');
374
374
  return { value: result, duration: performance.now() - start };
375
375
  } catch (ex) {
376
- this.handleProviderException('getOrganizationsForUser', ex, { scope: scope });
376
+ this.handleProviderException('getOrganizationsForUser', ex, {
377
+ scope: scope,
378
+ connectionId: connectionId,
379
+ });
377
380
  return { error: toError(ex), duration: performance.now() - start };
378
381
  }
379
382
  }
@@ -409,7 +412,7 @@ export abstract class GitHostIntegration<
409
412
  this.resetRequestExceptionCount('getProjectsForOrg');
410
413
  return { value: result, duration: performance.now() - start };
411
414
  } catch (ex) {
412
- this.handleProviderException('getProjectsForOrg', ex, { scope: scope });
415
+ this.handleProviderException('getProjectsForOrg', ex, { scope: scope, connectionId: connectionId });
413
416
  return { error: toError(ex), duration: performance.now() - start };
414
417
  }
415
418
  }
@@ -446,7 +449,10 @@ export abstract class GitHostIntegration<
446
449
  this.resetRequestExceptionCount('getRepositoriesForOrg');
447
450
  return { value: result, duration: performance.now() - start };
448
451
  } catch (ex) {
449
- this.handleProviderException('getRepositoriesForOrg', ex, { scope: scope });
452
+ this.handleProviderException('getRepositoriesForOrg', ex, {
453
+ scope: scope,
454
+ connectionId: options?.connectionId,
455
+ });
450
456
  return { error: toError(ex), duration: performance.now() - start };
451
457
  }
452
458
  }
@@ -484,7 +490,10 @@ export abstract class GitHostIntegration<
484
490
  this.resetRequestExceptionCount('getRepositoriesForUser');
485
491
  return { value: result, duration: performance.now() - start };
486
492
  } catch (ex) {
487
- this.handleProviderException('getRepositoriesForUser', ex, { scope: scope });
493
+ this.handleProviderException('getRepositoriesForUser', ex, {
494
+ scope: scope,
495
+ connectionId: options?.connectionId,
496
+ });
488
497
  return { error: toError(ex), duration: performance.now() - start };
489
498
  }
490
499
  }
@@ -1414,6 +1423,7 @@ export abstract class GitHostIntegration<
1414
1423
  this.handleProviderException('searchMyPullRequests', ex, {
1415
1424
  scope: scope,
1416
1425
  silent: true,
1426
+ connectionId: connectionId,
1417
1427
  });
1418
1428
  return {
1419
1429
  error: toError(ex),
@@ -1456,7 +1466,10 @@ export abstract class GitHostIntegration<
1456
1466
  this.resetRequestExceptionCount('getMyPullRequestsForUser');
1457
1467
  return { value: result, duration: performance.now() - start };
1458
1468
  } catch (ex) {
1459
- this.handleProviderException('getMyPullRequestsForUser', ex, { scope: scope });
1469
+ this.handleProviderException('getMyPullRequestsForUser', ex, {
1470
+ scope: scope,
1471
+ connectionId: connectionId,
1472
+ });
1460
1473
  return { error: toError(ex), duration: performance.now() - start };
1461
1474
  }
1462
1475
  }
@@ -1588,7 +1601,7 @@ export abstract class GitHostIntegration<
1588
1601
  this.resetRequestExceptionCount('searchPullRequests');
1589
1602
  return prs;
1590
1603
  } catch (ex) {
1591
- this.handleProviderException('searchPullRequests', ex, { scope: scope });
1604
+ this.handleProviderException('searchPullRequests', ex, { scope: scope, connectionId: connectionId });
1592
1605
  return undefined;
1593
1606
  }
1594
1607
  }
@@ -20,6 +20,7 @@ import type {
20
20
  } from '../authentication/integrationAuthenticationProvider.js';
21
21
  import type { IntegrationAuthenticationService } from '../authentication/integrationAuthenticationService.js';
22
22
  import type { ProviderAuthenticationSession } from '../authentication/models.js';
23
+ import { RejectedTokenTracker } from '../authentication/rejectedTokenTracker.js';
23
24
  import type { IntegrationIds, IssuesCloudHostIntegrationId } from '../constants.js';
24
25
  import { GitCloudHostIntegrationId } from '../constants.js';
25
26
  import type { IntegrationServiceContext } from '../context.js';
@@ -240,13 +241,18 @@ export abstract class IntegrationBase<
240
241
  // A truthy connectionId targets a specific account; an empty string is not a real target, so it falls
241
242
  // through to the primary path below.
242
243
  if (connectionId) {
244
+ // A read of this connection previously failed with an AuthenticationError, so the token in
245
+ // storage is known-refused. Ask for a refresh through the GK cloud before reading again, which
246
+ // is this branch's equivalent of the primary path's `refreshSessionIfExpired`. Claimed here so
247
+ // the forced refresh runs at most once per rejection.
248
+ const refreshRejectedToken = this._rejectedTokens.claimRefresh(connectionId);
243
249
  // Degrade to "no results" on failure, matching the primary path (whose ensureSession/
244
250
  // refreshSessionIfExpired swallow errors) so read methods keep their never-throws contract.
245
251
  try {
246
252
  const authProvider = await this.authenticationService.get(this.authProvider.id);
247
253
  const session = await authProvider.getSession(
248
254
  { ...this.authProviderDescriptor, connectionId: connectionId, cloud: true },
249
- { source: source },
255
+ { source: source, refreshRejectedToken: refreshRejectedToken },
250
256
  );
251
257
  return session != null && this.isSessionForIntegrationHost(session) ? session : undefined;
252
258
  } catch (ex) {
@@ -326,6 +332,10 @@ export abstract class IntegrationBase<
326
332
 
327
333
  @debug()
328
334
  async reauthenticate(): Promise<void> {
335
+ // `forceNewSession` below deletes the stored secrets to reconnect. Ahead of the guard — see
336
+ // `onStoredTokensReplaced`.
337
+ this.onStoredTokensReplaced();
338
+
329
339
  if (this._session === undefined) return;
330
340
 
331
341
  this._session = undefined;
@@ -343,6 +353,30 @@ export abstract class IntegrationBase<
343
353
  requestSessionSyncForUsecase(syncReqUsecase: SyncReqUsecase): void {
344
354
  this._syncRequestsPerFailedUsecase.add(syncReqUsecase);
345
355
  }
356
+
357
+ /**
358
+ * The per-connection counterpart of the primary session's expire-and-resync recovery: a token the
359
+ * provider refused is refreshed once through the GK cloud, which exchanges the refresh token server-side
360
+ * (the client never holds one). Armed by {@link handleProviderException} and consumed by
361
+ * {@link resolveReadSession}; a rejection it declines belongs to {@link trackRequestException} instead.
362
+ */
363
+ private readonly _rejectedTokens = new RejectedTokenTracker();
364
+
365
+ /**
366
+ * Called by every path that replaces or removes the stored tokens — a disconnect, a forced re-sync, a
367
+ * reauthentication, or the connection set changing. A rejection names a specific credential, so once that
368
+ * credential is gone the rejection describes nothing and must not force a refresh (or, for a deleted
369
+ * connection, outlive it).
370
+ *
371
+ * Deliberately NOT conditioned on `_session`. A per-connection read resolves through the auth provider and
372
+ * never populates the cached primary session, so the connections this recovery exists for are exactly the
373
+ * ones with no `_session` to inspect — the same blind spot the recovery itself was added to fix. Callers
374
+ * that guard on `_session` therefore invoke this ahead of that guard.
375
+ */
376
+ protected onStoredTokensReplaced(): void {
377
+ this._rejectedTokens.clear();
378
+ }
379
+
346
380
  private static readonly requestExceptionLimit = 5;
347
381
  private requestExceptionCount = 0;
348
382
 
@@ -350,6 +384,8 @@ export abstract class IntegrationBase<
350
384
  this.requestExceptionCount = 0;
351
385
  if (syncReqUsecase === 'all') {
352
386
  this._syncRequestsPerFailedUsecase.clear();
387
+ // 'all' is the whole-integration reset: a disconnect, or a re-sync that produced a new access token.
388
+ this.onStoredTokensReplaced();
353
389
  } else {
354
390
  this._syncRequestsPerFailedUsecase.delete(syncReqUsecase);
355
391
  }
@@ -374,6 +410,10 @@ export abstract class IntegrationBase<
374
410
  * `deleteConnection`). Unlike {@link reset}/{@link disconnect}, it deletes nothing from storage.
375
411
  */
376
412
  switchConnection(): void {
413
+ // A connection was deleted, or a different one became primary. Ahead of the guard — see
414
+ // `onStoredTokensReplaced`.
415
+ this.onStoredTokensReplaced();
416
+
377
417
  if (this._session === undefined) return;
378
418
 
379
419
  const wasConnected = this._session != null;
@@ -419,6 +459,9 @@ export abstract class IntegrationBase<
419
459
  // Reset our stored session so that we get a new one from the cloud
420
460
  const authProvider = await this.authenticationService.get(this.authProvider.id);
421
461
  await authProvider.deleteSession(this.authProviderDescriptor);
462
+ // The stored token was just deleted. Not left to the token-changed check below, which needs
463
+ // an `oldSession` — see `onStoredTokensReplaced`.
464
+ this.onStoredTokensReplaced();
422
465
  // Reset the session and clear our "stay disconnected" flag
423
466
  this._session = undefined;
424
467
  await this.ctx.storage.deleteWorkspace(this.connectedKey);
@@ -474,12 +517,29 @@ export abstract class IntegrationBase<
474
517
  protected handleProviderException(
475
518
  syncReqUsecase: SyncReqUsecase,
476
519
  ex: Error,
477
- options?: { scope?: ScopedLogger | undefined; silent?: boolean },
520
+ options?: { scope?: ScopedLogger | undefined; silent?: boolean; connectionId?: string },
478
521
  ): void {
479
522
  if (isCancellationError(ex)) return;
480
523
 
481
524
  options?.scope?.error(ex);
482
525
 
526
+ // A per-connection (multi-account) read resolved its session through `resolveReadSession`'s
527
+ // `connectionId` branch, which deliberately never touches the cached primary `_session`. So the
528
+ // primary-session recovery below cannot apply to it: expiring `_session` would mark a session this
529
+ // read never used, while the rejected connection kept its stored token and re-sent it on every
530
+ // later read — a token the provider has already refused (expired scopes, a revoked grant, an
531
+ // uninstalled app) is not self-healing, so the read failed identically until the user reconnected
532
+ // by hand. Record the rejection against the connection instead; `resolveReadSession` consumes it
533
+ // and forces the cloud `/refresh` on the next read of that connection. When there is nothing to
534
+ // recover, fall through to the shared failure budget, which disconnects after
535
+ // `requestExceptionLimit` and surfaces the reconnect prompt.
536
+ if (ex instanceof AuthenticationError && options?.connectionId) {
537
+ if (!this._rejectedTokens.recordRejection(options.connectionId)) {
538
+ this.trackRequestException(options);
539
+ }
540
+ return;
541
+ }
542
+
483
543
  if (ex instanceof AuthenticationError && this._session?.cloud) {
484
544
  if (!this.hasSessionSyncRequests()) {
485
545
  this.requestSessionSyncForUsecase(syncReqUsecase);
@@ -677,6 +737,7 @@ export abstract class IntegrationBase<
677
737
  const session = await this.resolveReadSession(connectionId, scope);
678
738
  if (session == null) return undefined;
679
739
 
740
+ const start = performance.now();
680
741
  try {
681
742
  const issues = await this.searchProviderMyIssues(
682
743
  session,
@@ -684,10 +745,10 @@ export abstract class IntegrationBase<
684
745
  cancellation,
685
746
  );
686
747
  this.resetRequestExceptionCount('searchMyIssues');
687
- return { value: issues };
748
+ return { value: issues, duration: performance.now() - start };
688
749
  } catch (ex) {
689
- this.handleProviderException('searchMyIssues', ex, { scope: scope });
690
- return { error: toError(ex) };
750
+ this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
751
+ return { error: toError(ex), duration: performance.now() - start };
691
752
  }
692
753
  }
693
754
 
@@ -738,7 +799,7 @@ export abstract class IntegrationBase<
738
799
  this.resetRequestExceptionCount('searchMyIssues');
739
800
  return { value: result };
740
801
  } catch (ex) {
741
- this.handleProviderException('searchMyIssues', ex, { scope: scope });
802
+ this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
742
803
  return { error: toError(ex) };
743
804
  }
744
805
  }
@@ -858,7 +919,10 @@ export abstract class IntegrationBase<
858
919
  return undefined;
859
920
  }
860
921
 
861
- this.handleProviderException('getCurrentAccount', ex, { scope: scope });
922
+ this.handleProviderException('getCurrentAccount', ex, {
923
+ scope: scope,
924
+ connectionId: connectionId,
925
+ });
862
926
 
863
927
  // Invalidate the cache on error, except for auth errors
864
928
  if (!(ex instanceof AuthenticationError)) {
@@ -48,7 +48,7 @@ export abstract class IssuesIntegration<
48
48
  this.resetRequestExceptionCount('getAccountForResource');
49
49
  return { value: account };
50
50
  } catch (ex) {
51
- this.handleProviderException('getAccountForResource', ex);
51
+ this.handleProviderException('getAccountForResource', ex, { connectionId: connectionId });
52
52
  return { error: toError(ex) };
53
53
  }
54
54
  }
@@ -80,7 +80,7 @@ export abstract class IssuesIntegration<
80
80
  this.resetRequestExceptionCount('getResourcesForUser');
81
81
  return { value: resources };
82
82
  } catch (ex) {
83
- this.handleProviderException('getResourcesForUser', ex);
83
+ this.handleProviderException('getResourcesForUser', ex, { connectionId: connectionId });
84
84
  return { error: toError(ex) };
85
85
  }
86
86
  }
@@ -108,7 +108,7 @@ export abstract class IssuesIntegration<
108
108
  this.resetRequestExceptionCount('getProjectsForResources');
109
109
  return { value: projects };
110
110
  } catch (ex) {
111
- this.handleProviderException('getProjectsForResources', ex);
111
+ this.handleProviderException('getProjectsForResources', ex, { connectionId: connectionId });
112
112
  return { error: toError(ex) };
113
113
  }
114
114
  }
@@ -161,7 +161,7 @@ export abstract class IssuesIntegration<
161
161
  this.resetRequestExceptionCount('getIssuesForProject');
162
162
  return { value: issues };
163
163
  } catch (ex) {
164
- this.handleProviderException('getIssuesForProject', ex);
164
+ this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
165
165
  return { error: toError(ex) };
166
166
  }
167
167
  }
@@ -188,7 +188,7 @@ export abstract class IssuesIntegration<
188
188
  this.resetRequestExceptionCount('getIssuesForProject');
189
189
  return { value: result };
190
190
  } catch (ex) {
191
- this.handleProviderException('getIssuesForProject', ex);
191
+ this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
192
192
  return { error: toError(ex) };
193
193
  }
194
194
  }
@@ -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
package/src/utils/date.ts CHANGED
@@ -149,9 +149,12 @@ export function unitThresholdMs(unit: Intl.RelativeTimeFormatUnit): number {
149
149
 
150
150
  // fromNow keeps its OWN single-pass loop (not routed through fromNowUnit) — one iteration, no
151
151
  // intermediate {unit,value} object allocation on this hot formatting path; fromNowUnit below iterates
152
- // the SAME `relativeUnitThresholds` array, so the two classifications can't drift apart.
153
- export function fromNow(date: Date | number, short?: boolean): string {
154
- const elapsed = (typeof date === 'number' ? date : date.getTime()) - Date.now();
152
+ // the SAME `relativeUnitThresholds` array, so the two classifications can't drift apart. `now` defaults
153
+ // to `Date.now()`, like fromNowUnit's pass it to pin the instant (a caller comparing against its own
154
+ // clock read otherwise sits one unpredictable tick away from the bucket it expects).
155
+ export function fromNow(date: Date | number, short?: boolean, now: Date | number = Date.now()): string {
156
+ const elapsed =
157
+ (typeof date === 'number' ? date : date.getTime()) - (typeof now === 'number' ? now : now.getTime());
155
158
  // Guard against invalid dates (e.g. `new Date(<unparseable>)`), since a non-finite value would throw in `Intl.RelativeTimeFormat.format`
156
159
  if (!Number.isFinite(elapsed)) return '';
157
160