@gitkraken/core-gitlens 0.4.100 → 0.5.100

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 (122) hide show
  1. package/CHANGELOG.md +12 -4
  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/providers/graph.d.ts +1 -15
  10. package/dist/git/providers/graph.d.ts.map +1 -1
  11. package/dist/git/providers/pausedOperations.d.ts +2 -0
  12. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  13. package/dist/git/utils/reachability.utils.d.ts +1 -1
  14. package/dist/git/utils/reachability.utils.js +1 -1
  15. package/dist/git-cli/providers/graph.d.ts +1 -3
  16. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  17. package/dist/git-cli/providers/graph.js +37 -214
  18. package/dist/git-cli/providers/graph.js.map +1 -1
  19. package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
  20. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  21. package/dist/git-cli/providers/pausedOperations.js +21 -1
  22. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  23. package/dist/git-cli/providers/staging.d.ts.map +1 -1
  24. package/dist/git-cli/providers/staging.js +10 -1
  25. package/dist/git-cli/providers/staging.js.map +1 -1
  26. package/dist/plus/git-github/providers/github/graph.d.ts +1 -3
  27. package/dist/plus/git-github/providers/github/graph.d.ts.map +1 -1
  28. package/dist/plus/git-github/providers/github/graph.js +8 -10
  29. package/dist/plus/git-github/providers/github/graph.js.map +1 -1
  30. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts +38 -48
  31. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts.map +1 -1
  32. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +22 -5
  33. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
  34. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts +39 -0
  35. package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts.map +1 -0
  36. package/dist/plus/integrations/authentication/rejectedTokenTracker.js +53 -0
  37. package/dist/plus/integrations/authentication/rejectedTokenTracker.js.map +1 -0
  38. package/dist/plus/integrations/errors.d.ts.map +1 -1
  39. package/dist/plus/integrations/errors.js +6 -2
  40. package/dist/plus/integrations/errors.js.map +1 -1
  41. package/dist/plus/integrations/integrationService.d.ts +8 -5
  42. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  43. package/dist/plus/integrations/integrationService.js +20 -652
  44. package/dist/plus/integrations/integrationService.js.map +1 -1
  45. package/dist/plus/integrations/models/gitHostIntegration.d.ts +10 -17
  46. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  47. package/dist/plus/integrations/models/gitHostIntegration.js +57 -94
  48. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  49. package/dist/plus/integrations/models/integration.d.ts +20 -0
  50. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  51. package/dist/plus/integrations/models/integration.js +61 -4
  52. package/dist/plus/integrations/models/integration.js.map +1 -1
  53. package/dist/plus/integrations/models/issuesIntegration.d.ts +4 -21
  54. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  55. package/dist/plus/integrations/models/issuesIntegration.js +10 -52
  56. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  57. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  58. package/dist/plus/integrations/reads/broaden.d.ts +20 -0
  59. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -0
  60. package/dist/plus/integrations/reads/broaden.js +326 -0
  61. package/dist/plus/integrations/reads/broaden.js.map +1 -0
  62. package/dist/plus/integrations/reads/context.d.ts +22 -0
  63. package/dist/plus/integrations/reads/context.d.ts.map +1 -1
  64. package/dist/plus/integrations/reads/hierarchy.d.ts +1 -1
  65. package/dist/plus/integrations/reads/hierarchy.d.ts.map +1 -1
  66. package/dist/plus/integrations/reads/hierarchy.js +109 -137
  67. package/dist/plus/integrations/reads/hierarchy.js.map +1 -1
  68. package/dist/plus/integrations/reads/hierarchy.utils.d.ts +25 -1
  69. package/dist/plus/integrations/reads/hierarchy.utils.d.ts.map +1 -1
  70. package/dist/plus/integrations/reads/hierarchy.utils.js +43 -0
  71. package/dist/plus/integrations/reads/hierarchy.utils.js.map +1 -1
  72. package/dist/plus/integrations/reads/issueTracker.d.ts +1 -1
  73. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  74. package/dist/plus/integrations/reads/issueTracker.js +13 -25
  75. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  76. package/dist/plus/integrations/reads/issues.d.ts +2 -2
  77. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  78. package/dist/plus/integrations/reads/issues.js.map +1 -1
  79. package/dist/plus/integrations/reads/pullRequests.d.ts +1 -1
  80. package/dist/plus/integrations/reads/pullRequests.d.ts.map +1 -1
  81. package/dist/plus/integrations/reads/pullRequests.js +1 -1
  82. package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
  83. package/dist/plus/integrations/reads/resolveRepository.d.ts +25 -0
  84. package/dist/plus/integrations/reads/resolveRepository.d.ts.map +1 -0
  85. package/dist/plus/integrations/reads/resolveRepository.js +235 -0
  86. package/dist/plus/integrations/reads/resolveRepository.js.map +1 -0
  87. package/dist/plus/integrations/reads/sweeps.d.ts +11 -0
  88. package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -0
  89. package/dist/plus/integrations/reads/sweeps.js +125 -0
  90. package/dist/plus/integrations/reads/sweeps.js.map +1 -0
  91. package/dist/utils/date.d.ts +1 -1
  92. package/dist/utils/date.d.ts.map +1 -1
  93. package/dist/utils/date.js +5 -3
  94. package/dist/utils/date.js.map +1 -1
  95. package/package.json +1 -1
  96. package/src/git/models/graph.ts +59 -41
  97. package/src/git/models/graphSession.ts +3 -106
  98. package/src/git/providers/graph.ts +1 -15
  99. package/src/git/providers/pausedOperations.ts +8 -1
  100. package/src/git/utils/reachability.utils.ts +1 -1
  101. package/src/git-cli/providers/graph.ts +37 -250
  102. package/src/git-cli/providers/pausedOperations.ts +29 -2
  103. package/src/git-cli/providers/staging.ts +8 -1
  104. package/src/plus/git-github/providers/github/graph.ts +8 -15
  105. package/src/plus/integrations/authentication/integrationAuthenticationProvider.ts +62 -40
  106. package/src/plus/integrations/authentication/rejectedTokenTracker.ts +56 -0
  107. package/src/plus/integrations/errors.ts +6 -2
  108. package/src/plus/integrations/integrationService.ts +27 -801
  109. package/src/plus/integrations/models/gitHostIntegration.ts +90 -99
  110. package/src/plus/integrations/models/integration.ts +68 -5
  111. package/src/plus/integrations/models/issuesIntegration.ts +10 -57
  112. package/src/plus/integrations/providers/azureDevOps.ts +1 -1
  113. package/src/plus/integrations/reads/broaden.ts +452 -0
  114. package/src/plus/integrations/reads/context.ts +24 -0
  115. package/src/plus/integrations/reads/hierarchy.ts +133 -153
  116. package/src/plus/integrations/reads/hierarchy.utils.ts +50 -1
  117. package/src/plus/integrations/reads/issueTracker.ts +15 -27
  118. package/src/plus/integrations/reads/issues.ts +4 -2
  119. package/src/plus/integrations/reads/pullRequests.ts +2 -9
  120. package/src/plus/integrations/reads/resolveRepository.ts +272 -0
  121. package/src/plus/integrations/reads/sweeps.ts +185 -0
  122. package/src/utils/date.ts +6 -3
@@ -104,6 +104,21 @@ export type SearchMyPullRequestsOptions = {
104
104
  includeReviewRequested?: boolean;
105
105
  };
106
106
 
107
+ /**
108
+ * Rejects a read the provider can't serve as asked: logs the reason and returns it as the `{ error }` half of an
109
+ * {@link IntegrationResult}, timed from `start`.
110
+ *
111
+ * A single builder because these refusals are a contract, not incidental returns — the read cores below reject on
112
+ * unsupported input, an inexpressible filter set, or an unresolvable filter account, and each one must reach the
113
+ * caller as an error it can surface as a warning rather than as an empty success. Writing the message once also
114
+ * keeps the logged text and the thrown text from drifting apart, which is exactly what happened while each site
115
+ * spelled its message out twice.
116
+ */
117
+ function unsupportedRead<T>(message: string, start: number, logContext?: string): IntegrationResult<T> {
118
+ Logger.warn(message, logContext);
119
+ return { error: new Error(message), duration: performance.now() - start };
120
+ }
121
+
107
122
  export abstract class GitHostIntegration<
108
123
  ID extends IntegrationIds = IntegrationIds,
109
124
  T extends ResourceDescriptor = ResourceDescriptor,
@@ -289,15 +304,6 @@ export abstract class GitHostIntegration<
289
304
  cancellation?: AbortSignal,
290
305
  ): Promise<RepositoryMetadata | undefined>;
291
306
 
292
- /**
293
- * Lists the organizations (orgs/workspaces/groups) the current user belongs to on this host.
294
- * `truncated === true` means the defensive page-drain backstop stopped before the upstream listing
295
- * was exhausted.
296
- */
297
- async getOrganizationsForUser(): Promise<ProviderHierarchyResult<ProviderOrganization> | undefined> {
298
- return (await this.getOrganizationsForUserResult())?.value;
299
- }
300
-
301
307
  /**
302
308
  * Whether this git host implements generic org discovery. False for providers that register no
303
309
  * {@link getProviderOrganizationsForUser} hook (e.g. Bitbucket Data Center) — the facade uses this to
@@ -344,9 +350,13 @@ export abstract class GitHostIntegration<
344
350
  }
345
351
 
346
352
  /**
347
- * Result-returning core of {@link getOrganizationsForUser}. Resolves the session for `connectionId`
348
- * (or the primary connection when omitted, honoring multi-account reads) and recovers a thrown error
349
- * into `{ error }` so callers can surface it as a warning instead of swallowing it to `undefined`.
353
+ * Lists the organizations (orgs/workspaces/groups) the current user belongs to on this host.
354
+ * `truncated === true` means the defensive page-drain backstop stopped before the upstream listing was
355
+ * exhausted.
356
+ *
357
+ * Resolves the session for `connectionId` (or the primary connection when omitted, honoring multi-account
358
+ * reads) and recovers a thrown error into `{ error }` so callers can surface it as a warning instead of
359
+ * swallowing it to `undefined`.
350
360
  */
351
361
  @trace()
352
362
  async getOrganizationsForUserResult(
@@ -363,7 +373,10 @@ export abstract class GitHostIntegration<
363
373
  this.resetRequestExceptionCount('getOrganizationsForUser');
364
374
  return { value: result, duration: performance.now() - start };
365
375
  } catch (ex) {
366
- this.handleProviderException('getOrganizationsForUser', ex, { scope: scope });
376
+ this.handleProviderException('getOrganizationsForUser', ex, {
377
+ scope: scope,
378
+ connectionId: connectionId,
379
+ });
367
380
  return { error: toError(ex), duration: performance.now() - start };
368
381
  }
369
382
  }
@@ -399,7 +412,7 @@ export abstract class GitHostIntegration<
399
412
  this.resetRequestExceptionCount('getProjectsForOrg');
400
413
  return { value: result, duration: performance.now() - start };
401
414
  } catch (ex) {
402
- this.handleProviderException('getProjectsForOrg', ex, { scope: scope });
415
+ this.handleProviderException('getProjectsForOrg', ex, { scope: scope, connectionId: connectionId });
403
416
  return { error: toError(ex), duration: performance.now() - start };
404
417
  }
405
418
  }
@@ -416,17 +429,9 @@ export abstract class GitHostIntegration<
416
429
  * every other host ignores it. (Azure without a project can't page its cross-project merge, so it
417
430
  * returns all matches in a single page.) `truncated === true` means the defensive page-drain
418
431
  * backstop stopped before the upstream listing was exhausted.
419
- */
420
- async getRepositoriesForOrg(
421
- org: string,
422
- options?: { project?: string; cursor?: string },
423
- ): Promise<ProviderHierarchyResult<ProviderRepository> | undefined> {
424
- return (await this.getRepositoriesForOrgResult(org, options))?.value;
425
- }
426
-
427
- /**
428
- * Result-returning core of {@link getRepositoriesForOrg}. Resolves the session for `connectionId`
429
- * (or the primary connection when omitted) and recovers a thrown error into `{ error }` for warnings.
432
+ *
433
+ * Resolves the session for `connectionId` (or the primary connection when omitted) and recovers a thrown
434
+ * error into `{ error }`, so a caller surfaces it as a warning instead of a silent `undefined`.
430
435
  */
431
436
  @trace()
432
437
  async getRepositoriesForOrgResult(
@@ -444,7 +449,10 @@ export abstract class GitHostIntegration<
444
449
  this.resetRequestExceptionCount('getRepositoriesForOrg');
445
450
  return { value: result, duration: performance.now() - start };
446
451
  } catch (ex) {
447
- this.handleProviderException('getRepositoriesForOrg', ex, { scope: scope });
452
+ this.handleProviderException('getRepositoriesForOrg', ex, {
453
+ scope: scope,
454
+ connectionId: options?.connectionId,
455
+ });
448
456
  return { error: toError(ex), duration: performance.now() - start };
449
457
  }
450
458
  }
@@ -482,7 +490,10 @@ export abstract class GitHostIntegration<
482
490
  this.resetRequestExceptionCount('getRepositoriesForUser');
483
491
  return { value: result, duration: performance.now() - start };
484
492
  } catch (ex) {
485
- this.handleProviderException('getRepositoriesForUser', ex, { scope: scope });
493
+ this.handleProviderException('getRepositoriesForUser', ex, {
494
+ scope: scope,
495
+ connectionId: options?.connectionId,
496
+ });
486
497
  return { error: toError(ex), duration: performance.now() - start };
487
498
  }
488
499
  }
@@ -706,11 +717,7 @@ export abstract class GitHostIntegration<
706
717
  (isAzureDevOpsProvider(providerId) &&
707
718
  !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null)))
708
719
  ) {
709
- Logger.warn(`Unsupported input for provider ${providerId}`, 'getIssuesForRepos');
710
- return {
711
- error: new Error(`Unsupported input for provider ${providerId}`),
712
- duration: performance.now() - start,
713
- };
720
+ return unsupportedRead(`Unsupported input for provider ${providerId}`, start, 'getIssuesForRepos');
714
721
  }
715
722
 
716
723
  let getIssuesOptions: GetIssuesOptions | undefined;
@@ -723,28 +730,24 @@ export abstract class GitHostIntegration<
723
730
  }
724
731
 
725
732
  if (organizations.size > 1) {
726
- Logger.warn(`Multiple organizations not supported for provider ${providerId}`, 'getIssuesForRepos');
727
- return {
728
- error: new Error(`Multiple organizations not supported for provider ${providerId}`),
729
- duration: performance.now() - start,
730
- };
733
+ return unsupportedRead(
734
+ `Multiple organizations not supported for provider ${providerId}`,
735
+ start,
736
+ 'getIssuesForRepos',
737
+ );
731
738
  } else if (organizations.size === 0) {
732
- Logger.warn(`No organizations found for provider ${providerId}`, 'getIssuesForRepos');
733
- return {
734
- error: new Error(`No organizations found for provider ${providerId}`),
735
- duration: performance.now() - start,
736
- };
739
+ return unsupportedRead(`No organizations found for provider ${providerId}`, start, 'getIssuesForRepos');
737
740
  }
738
741
 
739
742
  const organization: string = first(organizations.values())!;
740
743
 
741
744
  if (options?.filters != null) {
742
745
  if (!api.providerSupportsIssueFilters(providerId, options.filters)) {
743
- Logger.warn(`Unsupported filters for provider ${providerId}`, 'getIssuesForRepos');
744
- return {
745
- error: new Error(`Unsupported filters for provider ${providerId}`),
746
- duration: performance.now() - start,
747
- };
746
+ return unsupportedRead(
747
+ `Unsupported filters for provider ${providerId}`,
748
+ start,
749
+ 'getIssuesForRepos',
750
+ );
748
751
  }
749
752
 
750
753
  let userAccount: ProviderAccount | undefined;
@@ -756,21 +759,17 @@ export abstract class GitHostIntegration<
756
759
  }
757
760
 
758
761
  if (userAccount == null) {
759
- Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos');
760
- return {
761
- error: new Error(`Unable to get current user for ${providerId}`),
762
- duration: performance.now() - start,
763
- };
762
+ return unsupportedRead(`Unable to get current user for ${providerId}`, start, 'getIssuesForRepos');
764
763
  }
765
764
 
766
765
  const userFilterProperty = userAccount.name;
767
766
 
768
767
  if (userFilterProperty == null) {
769
- Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos');
770
- return {
771
- error: new Error(`Unable to get user property for filter for ${providerId}`),
772
- duration: performance.now() - start,
773
- };
768
+ return unsupportedRead(
769
+ `Unable to get user property for filter for ${providerId}`,
770
+ start,
771
+ 'getIssuesForRepos',
772
+ );
774
773
  }
775
774
 
776
775
  getIssuesOptions = {
@@ -889,11 +888,7 @@ export abstract class GitHostIntegration<
889
888
  // branch above applies. Without it an unsupported filter (e.g. GitLab has no Mention endpoint) would
890
889
  // resolve to no filter property being set and silently degrade to an unfiltered, project-wide read.
891
890
  if (!api.providerSupportsIssueFilters(providerId, options.filters)) {
892
- Logger.warn(`Unsupported filters for provider ${providerId}`, 'getIssuesForRepos');
893
- return {
894
- error: new Error(`Unsupported filters for provider ${providerId}`),
895
- duration: performance.now() - start,
896
- };
891
+ return unsupportedRead(`Unsupported filters for provider ${providerId}`, start, 'getIssuesForRepos');
897
892
  }
898
893
 
899
894
  let userAccount: ProviderAccount | undefined;
@@ -905,20 +900,16 @@ export abstract class GitHostIntegration<
905
900
  }
906
901
 
907
902
  if (userAccount == null) {
908
- Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos');
909
- return {
910
- error: new Error(`Unable to get current user for ${providerId}`),
911
- duration: performance.now() - start,
912
- };
903
+ return unsupportedRead(`Unable to get current user for ${providerId}`, start, 'getIssuesForRepos');
913
904
  }
914
905
 
915
906
  const userFilterProperty = userAccount.username;
916
907
  if (userFilterProperty == null) {
917
- Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos');
918
- return {
919
- error: new Error(`Unable to get user property for filter for ${providerId}`),
920
- duration: performance.now() - start,
921
- };
908
+ return unsupportedRead(
909
+ `Unable to get user property for filter for ${providerId}`,
910
+ start,
911
+ 'getIssuesForRepos',
912
+ );
922
913
  }
923
914
 
924
915
  getIssuesOptions = {
@@ -1124,21 +1115,17 @@ export abstract class GitHostIntegration<
1124
1115
  (isAzureDevOpsProvider(providerId) &&
1125
1116
  !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null)))
1126
1117
  ) {
1127
- Logger.warn(`Unsupported input for provider ${providerId}`);
1128
- return {
1129
- error: new Error(`Unsupported input for provider ${providerId}`),
1130
- duration: performance.now() - start,
1131
- };
1118
+ return unsupportedRead(`Unsupported input for provider ${providerId}`, start);
1132
1119
  }
1133
1120
 
1134
1121
  let getPullRequestsOptions: GetPullRequestsOptions | undefined;
1135
1122
  if (options?.filters != null) {
1136
1123
  if (!api.providerSupportsPullRequestFilters(providerId, options.filters)) {
1137
- Logger.warn(`Unsupported filters for provider ${providerId}`, 'getPullRequestsForRepos');
1138
- return {
1139
- error: new Error(`Unsupported filters for provider ${providerId}`),
1140
- duration: performance.now() - start,
1141
- };
1124
+ return unsupportedRead(
1125
+ `Unsupported filters for provider ${providerId}`,
1126
+ start,
1127
+ 'getPullRequestsForRepos',
1128
+ );
1142
1129
  }
1143
1130
 
1144
1131
  let userAccount: ProviderAccount | undefined;
@@ -1158,11 +1145,11 @@ export abstract class GitHostIntegration<
1158
1145
  duration: performance.now() - start,
1159
1146
  };
1160
1147
  } else if (organizations.size === 0) {
1161
- Logger.warn(`No organizations found for provider ${providerId}`, 'getPullRequestsForRepos');
1162
- return {
1163
- error: new Error(`No organizations found for provider ${providerId}`),
1164
- duration: performance.now() - start,
1165
- };
1148
+ return unsupportedRead(
1149
+ `No organizations found for provider ${providerId}`,
1150
+ start,
1151
+ 'getPullRequestsForRepos',
1152
+ );
1166
1153
  }
1167
1154
 
1168
1155
  const organization: string = first(organizations.values())!;
@@ -1182,11 +1169,11 @@ export abstract class GitHostIntegration<
1182
1169
  }
1183
1170
 
1184
1171
  if (userAccount == null) {
1185
- Logger.warn(`Unable to get current user for ${providerId}`, 'getPullRequestsForRepos');
1186
- return {
1187
- error: new Error(`Unable to get current user for ${providerId}`),
1188
- duration: performance.now() - start,
1189
- };
1172
+ return unsupportedRead(
1173
+ `Unable to get current user for ${providerId}`,
1174
+ start,
1175
+ 'getPullRequestsForRepos',
1176
+ );
1190
1177
  }
1191
1178
 
1192
1179
  let userFilterProperty: string | null;
@@ -1202,11 +1189,11 @@ export abstract class GitHostIntegration<
1202
1189
  }
1203
1190
 
1204
1191
  if (userFilterProperty == null) {
1205
- Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getPullRequestsForRepos');
1206
- return {
1207
- error: new Error(`Unable to get user property for filter for ${providerId}`),
1208
- duration: performance.now() - start,
1209
- };
1192
+ return unsupportedRead(
1193
+ `Unable to get user property for filter for ${providerId}`,
1194
+ start,
1195
+ 'getPullRequestsForRepos',
1196
+ );
1210
1197
  }
1211
1198
 
1212
1199
  // Route the "review requested from me" filter to the field each provider actually reads:
@@ -1436,6 +1423,7 @@ export abstract class GitHostIntegration<
1436
1423
  this.handleProviderException('searchMyPullRequests', ex, {
1437
1424
  scope: scope,
1438
1425
  silent: true,
1426
+ connectionId: connectionId,
1439
1427
  });
1440
1428
  return {
1441
1429
  error: toError(ex),
@@ -1478,7 +1466,10 @@ export abstract class GitHostIntegration<
1478
1466
  this.resetRequestExceptionCount('getMyPullRequestsForUser');
1479
1467
  return { value: result, duration: performance.now() - start };
1480
1468
  } catch (ex) {
1481
- this.handleProviderException('getMyPullRequestsForUser', ex, { scope: scope });
1469
+ this.handleProviderException('getMyPullRequestsForUser', ex, {
1470
+ scope: scope,
1471
+ connectionId: connectionId,
1472
+ });
1482
1473
  return { error: toError(ex), duration: performance.now() - start };
1483
1474
  }
1484
1475
  }
@@ -1610,7 +1601,7 @@ export abstract class GitHostIntegration<
1610
1601
  this.resetRequestExceptionCount('searchPullRequests');
1611
1602
  return prs;
1612
1603
  } catch (ex) {
1613
- this.handleProviderException('searchPullRequests', ex, { scope: scope });
1604
+ this.handleProviderException('searchPullRequests', ex, { scope: scope, connectionId: connectionId });
1614
1605
  return undefined;
1615
1606
  }
1616
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);
@@ -686,7 +746,7 @@ export abstract class IntegrationBase<
686
746
  this.resetRequestExceptionCount('searchMyIssues');
687
747
  return { value: issues };
688
748
  } catch (ex) {
689
- this.handleProviderException('searchMyIssues', ex, { scope: scope });
749
+ this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
690
750
  return { error: toError(ex) };
691
751
  }
692
752
  }
@@ -738,7 +798,7 @@ export abstract class IntegrationBase<
738
798
  this.resetRequestExceptionCount('searchMyIssues');
739
799
  return { value: result };
740
800
  } catch (ex) {
741
- this.handleProviderException('searchMyIssues', ex, { scope: scope });
801
+ this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
742
802
  return { error: toError(ex) };
743
803
  }
744
804
  }
@@ -858,7 +918,10 @@ export abstract class IntegrationBase<
858
918
  return undefined;
859
919
  }
860
920
 
861
- this.handleProviderException('getCurrentAccount', ex, { scope: scope });
921
+ this.handleProviderException('getCurrentAccount', ex, {
922
+ scope: scope,
923
+ connectionId: connectionId,
924
+ });
862
925
 
863
926
  // Invalidate the cache on error, except for auth errors
864
927
  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,38 +80,20 @@ 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
  }
87
87
 
88
88
  protected abstract getProviderResourcesForUser(session: ProviderAuthenticationSession): Promise<T[] | undefined>;
89
89
 
90
- @trace()
91
- async getProjectsForResources(resources: T[], connectionId?: string): Promise<T[] | undefined> {
92
- return (await this.getProjectsForResourcesResult(resources, connectionId))?.value;
93
- }
94
-
95
- /**
96
- * Result-returning core of {@link getProjectsForResources}. Recovers thrown errors into `{ error }` so callers
97
- * can surface them as warnings rather than silently swallowing them to `undefined`. Implemented by unwrapping
98
- * the metadata-aware path's `values` so the array-returning contract stays backward compatible.
99
- */
100
- async getProjectsForResourcesResult(
101
- resources: T[],
102
- connectionId?: string,
103
- ): Promise<IntegrationResult<T[] | undefined>> {
104
- const result = await this.getProjectsForResourcesWithMetadataResult(resources, connectionId);
105
- if (result == null) return undefined;
106
- if (result.error != null) return { value: result.value?.values, error: result.error };
107
- return { value: result.value?.values };
108
- }
109
-
110
90
  /**
111
- * Metadata-aware counterpart of {@link getProjectsForResourcesResult} for ProviderBackend composition:
112
- * returns the SDK collection `{ values, metadata }` (completeness + per-resource failures) so callers can
113
- * warn on failed resources and set `fetchFailed` without discarding the resources that succeeded.
91
+ * Project discovery for a set of resources, returning the SDK collection `{ values, metadata }` (completeness
92
+ * + per-resource failures) so callers can warn on failed resources and set `fetchFailed` without discarding
93
+ * the resources that succeeded. Thrown errors are recovered into `{ error }` for the same reason: a caller
94
+ * surfaces them as a warning rather than swallowing them to `undefined`.
114
95
  */
96
+ @trace()
115
97
  async getProjectsForResourcesWithMetadataResult(
116
98
  resources: T[],
117
99
  connectionId?: string,
@@ -126,40 +108,11 @@ export abstract class IssuesIntegration<
126
108
  this.resetRequestExceptionCount('getProjectsForResources');
127
109
  return { value: projects };
128
110
  } catch (ex) {
129
- this.handleProviderException('getProjectsForResources', ex);
111
+ this.handleProviderException('getProjectsForResources', ex, { connectionId: connectionId });
130
112
  return { error: toError(ex) };
131
113
  }
132
114
  }
133
115
 
134
- async getProjectsForUser(connectionId?: string): Promise<T[] | undefined> {
135
- return (await this.getProjectsForUserResult(connectionId))?.value;
136
- }
137
-
138
- /**
139
- * Result-returning core of {@link getProjectsForUser}. Composes the resource and project result methods so
140
- * callers can surface per-step warnings. Unwraps the metadata-aware path for the array-returning contract.
141
- */
142
- async getProjectsForUserResult(connectionId?: string): Promise<IntegrationResult<T[] | undefined>> {
143
- const result = await this.getProjectsForUserWithMetadataResult(connectionId);
144
- if (result == null) return undefined;
145
- if (result.error != null) return { value: result.value?.values, error: result.error };
146
- return { value: result.value?.values };
147
- }
148
-
149
- /**
150
- * Metadata-aware counterpart of {@link getProjectsForUserResult}: composes resource discovery with the
151
- * metadata-aware project read so ProviderBackend consumers get completeness/failures across resources.
152
- */
153
- async getProjectsForUserWithMetadataResult(
154
- connectionId?: string,
155
- ): Promise<IntegrationResult<ProviderApiCollectionResult<T> | undefined>> {
156
- const resources = await this.getResourcesForUserResult(connectionId);
157
- if (resources?.error != null) return { error: resources.error };
158
- if (resources?.value == null) return undefined;
159
-
160
- return this.getProjectsForResourcesWithMetadataResult(resources.value, connectionId);
161
- }
162
-
163
116
  /**
164
117
  * Metadata-aware provider project discovery. The default wraps the array-returning
165
118
  * {@link getProviderProjectsForResources} in `{ values }` with no metadata, so providers without a
@@ -208,7 +161,7 @@ export abstract class IssuesIntegration<
208
161
  this.resetRequestExceptionCount('getIssuesForProject');
209
162
  return { value: issues };
210
163
  } catch (ex) {
211
- this.handleProviderException('getIssuesForProject', ex);
164
+ this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
212
165
  return { error: toError(ex) };
213
166
  }
214
167
  }
@@ -235,7 +188,7 @@ export abstract class IssuesIntegration<
235
188
  this.resetRequestExceptionCount('getIssuesForProject');
236
189
  return { value: result };
237
190
  } catch (ex) {
238
- this.handleProviderException('getIssuesForProject', ex);
191
+ this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
239
192
  return { error: toError(ex) };
240
193
  }
241
194
  }
@@ -1062,7 +1062,7 @@ export abstract class AzureDevOpsIntegrationBase<
1062
1062
  { providerId: this.id, resourceId: p.resourceId, projectId: p.name },
1063
1063
  );
1064
1064
  return {
1065
- issues: result.values.map(i => fromProviderIssue(i, this as any, { project: p })),
1065
+ issues: result.values.map(i => fromProviderIssue(i, this, { project: p })),
1066
1066
  // Azure work-item ids are organization-scoped. Include both org and project so the
1067
1067
  // assigned/authored passes dedupe the same item without collapsing another org's item.
1068
1068
  projectKey: `${p.resourceId}:${p.id}`,