@gitkraken/core-gitlens 0.10.0 → 0.10.2

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 (35) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/dist/plus/integrations/authentication/cloudIntegrationService.js +24 -7
  3. package/dist/plus/integrations/authentication/cloudIntegrationService.js.map +1 -1
  4. package/dist/plus/integrations/authentication/configuredIntegrationService.d.ts +40 -27
  5. package/dist/plus/integrations/authentication/configuredIntegrationService.js +92 -22
  6. package/dist/plus/integrations/authentication/configuredIntegrationService.js.map +1 -1
  7. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts +7 -0
  8. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +22 -8
  9. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
  10. package/dist/plus/integrations/integrationService.js +6 -3
  11. package/dist/plus/integrations/integrationService.js.map +1 -1
  12. package/dist/plus/integrations/models/gitHostIntegration.js +38 -36
  13. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  14. package/dist/plus/integrations/models/integration.d.ts +71 -7
  15. package/dist/plus/integrations/models/integration.js +201 -55
  16. package/dist/plus/integrations/models/integration.js.map +1 -1
  17. package/dist/plus/integrations/models/issuesIntegration.js +9 -9
  18. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  19. package/dist/plus/integrations/providers/azureDevOps.js +1 -1
  20. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  21. package/dist/plus/integrations/providers/azureDevOpsServer.js +1 -1
  22. package/dist/plus/integrations/providers/azureDevOpsServer.js.map +1 -1
  23. package/dist/plus/integrations/providers/bitbucket-server.js +1 -1
  24. package/dist/plus/integrations/providers/bitbucket-server.js.map +1 -1
  25. package/dist/plus/integrations/providers/bitbucket.js +1 -1
  26. package/dist/plus/integrations/providers/bitbucket.js.map +1 -1
  27. package/dist/plus/integrations/providers/github.js +3 -6
  28. package/dist/plus/integrations/providers/github.js.map +1 -1
  29. package/dist/plus/integrations/providers/gitlab.js +1 -1
  30. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  31. package/dist/plus/integrations/providers/jira.js +1 -0
  32. package/dist/plus/integrations/providers/jira.js.map +1 -1
  33. package/dist/plus/integrations/providers/linear.js +1 -0
  34. package/dist/plus/integrations/providers/linear.js.map +1 -1
  35. package/package.json +1 -1
@@ -37,6 +37,12 @@ export type IntegrationResult<T> = {
37
37
  duration?: number;
38
38
  value?: never;
39
39
  } | undefined;
40
+ /** A per-connection read's session that could not be resolved for a retryable reason (see `resolveReadSession`). */
41
+ export declare class ReadSessionFailure {
42
+ readonly error: Error;
43
+ constructor(error: Error);
44
+ }
45
+ export declare function isReadSessionFailure(session: ProviderAuthenticationSession | ReadSessionFailure | undefined): session is ReadSessionFailure;
40
46
  /**
41
47
  * One target's answer in a batch read. A slot rejected by an authentication refusal carries the scope failure it was
42
48
  * recorded as (see `IntegrationBase.settleBatchRefusals`).
@@ -158,26 +164,65 @@ export declare abstract class IntegrationBase<ID extends IntegrationIds = Integr
158
164
  protected getSessionFingerprint(session: ProviderAuthenticationSession): string;
159
165
  get connectionExpired(): boolean | undefined;
160
166
  protected _session: ProviderAuthenticationSession | null | undefined;
167
+ /**
168
+ * Every change to the session (a resolution, a forced re-sync, a disconnect, a reauthentication, a connection
169
+ * switch, a refused token) runs here, one at a time. Each one reads storage and the cloud and then settles
170
+ * `_session`, the stored tokens and the connected flag; run concurrently, one would publish what it read over
171
+ * what another settled meanwhile (gitkraken/kepler#3546). Reads don't queue: they read `_session` as it is.
172
+ */
173
+ private _sessionTransitions;
174
+ /**
175
+ * Runs `fn` after every transition queued before it. `fn` must not queue another transition and wait for it,
176
+ * which would wait for itself: inside a transition, call the `*Locked` methods instead.
177
+ */
178
+ protected runSessionTransition<T>(fn: () => Promise<T>): Promise<T>;
179
+ /** An expired session is refreshed first; when that fails, nothing is returned rather than a token the provider refuses. */
161
180
  getSession(source: Sources): ProviderAuthenticationSession | Promise<ProviderAuthenticationSession | undefined> | undefined;
181
+ private getRefreshedSession;
162
182
  private isSessionForIntegrationHost;
163
183
  /**
164
184
  * Resolves the session to read as, for a per-connection (multi-account) read. When `connectionId` is
165
185
  * omitted this is the integration's primary session, resolved exactly like the existing read flow
166
186
  * (ensure-connected + refresh-if-expired). When set, it resolves THAT connection's session directly
167
187
  * from the auth provider — refreshing it if expired — WITHOUT disturbing the cached primary
168
- * `_session`. Returns undefined when the requested session can't be resolved (e.g. the connection is
169
- * gone or the provider isn't connected), so callers degrade to "no results".
188
+ * `_session`. Never throws: `undefined` when there is no session to read with (the connection is gone,
189
+ * the provider isn't connected), `{ error }` when the connection's token could not be fetched.
190
+ *
191
+ * The two must not be conflated. A token fetch that THROWS is, by `getConnectionSession`'s contract, a
192
+ * retryable failure (a rate-limited or failing GK API, a network error), not evidence that the connection is
193
+ * gone (#5569). Answering "no session" for it made every connection read during a GK API 429 report
194
+ * `no-connection`, which a consumer rightly treats as a credential to reconnect (gitkraken/kepler#3546).
195
+ * Returned as the read's error instead, the read core surfaces it like any other failed request: a rate
196
+ * limit as `rate-limit`, anything else as `other`.
197
+ */
198
+ protected resolveReadSession(connectionId: string | undefined, scope: ScopedLogger | undefined, source?: Sources): Promise<ProviderAuthenticationSession | ReadSessionFailure | undefined>;
199
+ /**
200
+ * The session lookups' prelude for a caller that can surface a failure (`throwOnError`): the refresh's failure to
201
+ * report, or `'unconnected'`, so a throttled refresh is not mistaken for "no pull request" and kept.
202
+ */
203
+ protected prepareSessionLookup(): Promise<Error | 'unconnected' | undefined>;
204
+ /**
205
+ * {@link resolveReadSession} for a read that already rethrows its provider's failures (a repository or account
206
+ * lookup), or whose callers treat any failure as "nothing to show": a token that could not be fetched is
207
+ * thrown like those failures, so it is classified as the failed request it is rather than as a missing
208
+ * connection.
170
209
  */
171
- protected resolveReadSession(connectionId: string | undefined, scope: ScopedLogger | undefined, source?: Sources): Promise<ProviderAuthenticationSession | undefined>;
210
+ protected resolveReadSessionOrThrow(connectionId: string | undefined, scope: ScopedLogger | undefined): Promise<ProviderAuthenticationSession | undefined>;
172
211
  connect(source: Sources): Promise<boolean>;
173
212
  protected providerOnConnect?(): void | Promise<void>;
174
213
  disconnect(options?: {
175
214
  silent?: boolean;
176
215
  currentSessionOnly?: boolean;
177
216
  }): Promise<void>;
217
+ private disconnectLocked;
178
218
  protected providerOnDisconnect?(): void | Promise<void>;
179
219
  reauthenticate(): Promise<void>;
180
220
  refresh(): void;
221
+ /**
222
+ * Drops the cached session when its token is no longer `accessToken` (a session replaced outside the
223
+ * integration) and resolves it again, in one transition, so nothing in flight publishes the replaced one.
224
+ */
225
+ protected resyncSessionIfTokenChanged(accessToken: string | undefined): Promise<void>;
181
226
  private _syncRequestsPerFailedUsecase;
182
227
  hasSessionSyncRequests(): boolean;
183
228
  requestSessionSyncForUsecase(syncReqUsecase: SyncReqUsecase): void;
@@ -221,10 +266,14 @@ export declare abstract class IntegrationBase<ID extends IntegrationIds = Integr
221
266
  * connection changed underneath a warm integration (e.g. after `setPrimaryConnection`/
222
267
  * `deleteConnection`). Unlike {@link reset}/{@link disconnect}, it deletes nothing from storage.
223
268
  */
224
- switchConnection(): void;
225
- private refreshAfterSwitch;
269
+ switchConnection(): Promise<void>;
226
270
  private skippedNonCloudReported;
227
- syncCloudConnection(state: 'connected' | 'disconnected', forceSync: boolean): Promise<void>;
271
+ /**
272
+ * Resolves the re-sync's failure, when its token fetch threw (cancellations included), so a caller can report it.
273
+ * Never rejects: the cloud sync fans this out over every integration and must not fail on one.
274
+ */
275
+ syncCloudConnection(state: 'connected' | 'disconnected', forceSync: boolean): Promise<Error | undefined>;
276
+ private syncCloudConnectionLocked;
228
277
  /**
229
278
  * Proves the credential with an uncached check, rejecting with the provider's `AuthenticationError` when it is
230
279
  * refused, and with anything else when the check proves nothing. A `403` proves nothing either: the credential
@@ -334,13 +383,28 @@ export declare abstract class IntegrationBase<ID extends IntegrationIds = Integr
334
383
  silent?: boolean;
335
384
  connectionId?: string;
336
385
  }): boolean;
386
+ private expireSessionIfToken;
387
+ /** Counts completed cloud re-syncs, so a refresh queued behind one reuses its outcome rather than running another. */
388
+ private _resyncs;
389
+ private _lastResyncFailure;
337
390
  private missingExpirityReported;
338
- protected refreshSessionIfExpired(scope?: ScopedLogger): Promise<void>;
391
+ /**
392
+ * Resolves the refresh's failure while the session it could not replace is still unusable, so a read can report
393
+ * that instead of reading with an expired token or as a missing connection.
394
+ */
395
+ protected refreshSessionIfExpired(): Promise<Error | undefined>;
339
396
  trackRequestException(options?: {
340
397
  silent?: boolean;
341
398
  }): void;
342
399
  isConnected(): Promise<boolean>;
343
400
  private ensureSession;
401
+ /**
402
+ * Asks the auth provider for a session interactively, storing what it gets; whether one came back. A declined
403
+ * consent or any other failure is not one, and never throws: it is resolved, like any read, from storage.
404
+ */
405
+ private signIn;
406
+ /** Resolves and publishes the session. Only ever runs inside a transition (see {@link runSessionTransition}). */
407
+ private resolveSessionLocked;
344
408
  getIgnoreSSLErrors(): boolean | 'force';
345
409
  searchMyIssues(resource?: ResourceDescriptor, cancellation?: AbortSignal, connectionId?: string): Promise<IssueShape[] | undefined>;
346
410
  searchMyIssues(resources?: ResourceDescriptor[], cancellation?: AbortSignal, connectionId?: string): Promise<IssueShape[] | undefined>;
@@ -19,6 +19,16 @@ import { providersMetadata } from "../providers/models.js";
19
19
  import { RejectedTokenTracker } from "../authentication/rejectedTokenTracker.js";
20
20
  import { getCachedIssue } from "./issueCache.js";
21
21
  //#region ../plus/integrations/src/models/integration.ts
22
+ /** A per-connection read's session that could not be resolved for a retryable reason (see `resolveReadSession`). */
23
+ var ReadSessionFailure = class {
24
+ error;
25
+ constructor(error) {
26
+ this.error = error;
27
+ }
28
+ };
29
+ function isReadSessionFailure(session) {
30
+ return session instanceof ReadSessionFailure;
31
+ }
22
32
  /** Every {@link PullRequestEtagInclude}, in the canonical order an etag lists them in. */
23
33
  const pullRequestEtagIncludes = [
24
34
  "mergeable",
@@ -133,12 +143,35 @@ var IntegrationBase = class IntegrationBase {
133
143
  return new Date(this._session.expiresAt) < /* @__PURE__ */ new Date();
134
144
  }
135
145
  _session;
146
+ /**
147
+ * Every change to the session (a resolution, a forced re-sync, a disconnect, a reauthentication, a connection
148
+ * switch, a refused token) runs here, one at a time. Each one reads storage and the cloud and then settles
149
+ * `_session`, the stored tokens and the connected flag; run concurrently, one would publish what it read over
150
+ * what another settled meanwhile (gitkraken/kepler#3546). Reads don't queue: they read `_session` as it is.
151
+ */
152
+ _sessionTransitions = Promise.resolve();
153
+ /**
154
+ * Runs `fn` after every transition queued before it. `fn` must not queue another transition and wait for it,
155
+ * which would wait for itself: inside a transition, call the `*Locked` methods instead.
156
+ */
157
+ runSessionTransition(fn) {
158
+ const run = this._sessionTransitions.then(fn);
159
+ this._sessionTransitions = run.then(() => void 0, () => void 0);
160
+ return run;
161
+ }
162
+ /** An expired session is refreshed first; when that fails, nothing is returned rather than a token the provider refuses. */
136
163
  getSession(source) {
137
164
  if (this._session === void 0) return this.ensureSession({
138
165
  createIfNeeded: false,
139
166
  source
140
167
  });
141
- return this._session != null && this.isSessionForIntegrationHost(this._session) ? this._session : void 0;
168
+ if (this._session == null || !this.isSessionForIntegrationHost(this._session)) return void 0;
169
+ if (this.connectionExpired) return this.getRefreshedSession();
170
+ return this._session;
171
+ }
172
+ async getRefreshedSession() {
173
+ if (await this.refreshSessionIfExpired() != null) return void 0;
174
+ return this._session != null && this.connectionExpired !== true && this.isSessionForIntegrationHost(this._session) ? this._session : void 0;
142
175
  }
143
176
  isSessionForIntegrationHost(session) {
144
177
  if (!isSelfManagedHostIntegrationId(this.id)) return true;
@@ -149,8 +182,15 @@ var IntegrationBase = class IntegrationBase {
149
182
  * omitted this is the integration's primary session, resolved exactly like the existing read flow
150
183
  * (ensure-connected + refresh-if-expired). When set, it resolves THAT connection's session directly
151
184
  * from the auth provider — refreshing it if expired — WITHOUT disturbing the cached primary
152
- * `_session`. Returns undefined when the requested session can't be resolved (e.g. the connection is
153
- * gone or the provider isn't connected), so callers degrade to "no results".
185
+ * `_session`. Never throws: `undefined` when there is no session to read with (the connection is gone,
186
+ * the provider isn't connected), `{ error }` when the connection's token could not be fetched.
187
+ *
188
+ * The two must not be conflated. A token fetch that THROWS is, by `getConnectionSession`'s contract, a
189
+ * retryable failure (a rate-limited or failing GK API, a network error), not evidence that the connection is
190
+ * gone (#5569). Answering "no session" for it made every connection read during a GK API 429 report
191
+ * `no-connection`, which a consumer rightly treats as a credential to reconnect (gitkraken/kepler#3546).
192
+ * Returned as the read's error instead, the read core surfaces it like any other failed request: a rate
193
+ * limit as `rate-limit`, anything else as `other`.
154
194
  */
155
195
  async resolveReadSession(connectionId, scope, source) {
156
196
  if (this.ctx.config.isIntegrationsEnabled?.() === false || this.ctx.storage.getWorkspace(this.connectedKey) === false) return;
@@ -167,14 +207,37 @@ var IntegrationBase = class IntegrationBase {
167
207
  });
168
208
  return session != null && this.isSessionForIntegrationHost(session) ? session : void 0;
169
209
  } catch (ex) {
210
+ if (isCancellationError(ex)) return void 0;
170
211
  scope?.error(ex);
171
- return;
212
+ return new ReadSessionFailure(toError(ex));
172
213
  }
173
214
  }
174
215
  if (!(this.maybeConnected ?? await this.isConnected())) return void 0;
175
- await this.refreshSessionIfExpired(scope);
216
+ const refreshFailure = await this.refreshSessionIfExpired();
217
+ if (refreshFailure != null) return new ReadSessionFailure(refreshFailure);
176
218
  return this._session != null && this.isSessionForIntegrationHost(this._session) ? this._session : void 0;
177
219
  }
220
+ /**
221
+ * The session lookups' prelude for a caller that can surface a failure (`throwOnError`): the refresh's failure to
222
+ * report, or `'unconnected'`, so a throttled refresh is not mistaken for "no pull request" and kept.
223
+ */
224
+ async prepareSessionLookup() {
225
+ if (!(this.maybeConnected ?? await this.isConnected())) return "unconnected";
226
+ const failure = await this.refreshSessionIfExpired();
227
+ if (failure != null) return failure;
228
+ return this._session != null ? void 0 : "unconnected";
229
+ }
230
+ /**
231
+ * {@link resolveReadSession} for a read that already rethrows its provider's failures (a repository or account
232
+ * lookup), or whose callers treat any failure as "nothing to show": a token that could not be fetched is
233
+ * thrown like those failures, so it is classified as the failed request it is rather than as a missing
234
+ * connection.
235
+ */
236
+ async resolveReadSessionOrThrow(connectionId, scope) {
237
+ const session = await this.resolveReadSession(connectionId, scope);
238
+ if (isReadSessionFailure(session)) throw session.error;
239
+ return session;
240
+ }
178
241
  async connect(source) {
179
242
  try {
180
243
  return Boolean(await this.ensureSession({
@@ -187,9 +250,8 @@ var IntegrationBase = class IntegrationBase {
187
250
  }
188
251
  async disconnect(options) {
189
252
  if (options?.currentSessionOnly && this._session === null) return;
190
- const connected = this._session != null;
191
253
  let signOut = !options?.currentSessionOnly;
192
- if (connected && !options?.currentSessionOnly && !options?.silent) {
254
+ if (this._session != null && !options?.currentSessionOnly && !options?.silent) {
193
255
  const decision = await this.ctx.hooks?.onConfirmDisconnect?.({
194
256
  integrationName: this.name,
195
257
  offerSignOut: this.authenticationService.supports(this.authProvider.id)
@@ -197,6 +259,11 @@ var IntegrationBase = class IntegrationBase {
197
259
  if (decision == null) return;
198
260
  signOut = decision.signOut;
199
261
  }
262
+ await this.runSessionTransition(() => this.disconnectLocked(options, signOut));
263
+ }
264
+ async disconnectLocked(options, signOut) {
265
+ if (options?.currentSessionOnly && this._session === null) return;
266
+ const connected = this._session != null;
200
267
  if (signOut) await (await this.authenticationService.get(this.authProvider.id)).deleteAllSessions(this.authProviderDescriptor);
201
268
  this.resetRequestExceptionCount("all");
202
269
  this._session = null;
@@ -213,8 +280,11 @@ var IntegrationBase = class IntegrationBase {
213
280
  }
214
281
  async reauthenticate() {
215
282
  this.onStoredTokensReplaced();
216
- if (this._session === void 0) return;
217
- this._session = void 0;
283
+ if (!await this.runSessionTransition(() => {
284
+ if (this._session === void 0) return Promise.resolve(false);
285
+ this._session = void 0;
286
+ return Promise.resolve(true);
287
+ })) return;
218
288
  await this.ensureSession({
219
289
  createIfNeeded: true,
220
290
  forceNewSession: true
@@ -223,6 +293,16 @@ var IntegrationBase = class IntegrationBase {
223
293
  refresh() {
224
294
  this.ensureSession({ createIfNeeded: false });
225
295
  }
296
+ /**
297
+ * Drops the cached session when its token is no longer `accessToken` (a session replaced outside the
298
+ * integration) and resolves it again, in one transition, so nothing in flight publishes the replaced one.
299
+ */
300
+ resyncSessionIfTokenChanged(accessToken) {
301
+ return this.runSessionTransition(async () => {
302
+ if (this._session != null && this._session.accessToken !== accessToken) this._session = void 0;
303
+ await this.resolveSessionLocked({ createIfNeeded: false });
304
+ });
305
+ }
226
306
  _syncRequestsPerFailedUsecase = /* @__PURE__ */ new Set();
227
307
  hasSessionSyncRequests() {
228
308
  return this._syncRequestsPerFailedUsecase.size > 0;
@@ -276,8 +356,10 @@ var IntegrationBase = class IntegrationBase {
276
356
  this.requestExceptionCount = 0;
277
357
  }
278
358
  async reset() {
279
- await this.disconnect({ silent: true });
280
- await this.ctx.storage.deleteWorkspace(this.connectedKey);
359
+ await this.runSessionTransition(async () => {
360
+ await this.disconnectLocked({ silent: true }, true);
361
+ await this.ctx.storage.deleteWorkspace(this.connectedKey);
362
+ });
281
363
  }
282
364
  /**
283
365
  * Drops the in-memory session so the next access re-resolves it from storage. Used when the primary
@@ -286,24 +368,30 @@ var IntegrationBase = class IntegrationBase {
286
368
  */
287
369
  switchConnection() {
288
370
  this.onStoredTokensReplaced();
289
- if (this._session === void 0) return;
290
- const wasConnected = this._session != null;
291
- this._session = void 0;
292
- this._onDidChange.fire();
293
- this.refreshAfterSwitch(wasConnected);
294
- }
295
- async refreshAfterSwitch(wasConnected) {
296
- if (await this.ensureSession({ createIfNeeded: false }) != null || !wasConnected) return;
297
- this._onDidChange.fire();
298
- this.didChangeConnection?.fire({
299
- integration: this,
300
- key: this.key,
301
- reason: "disconnected"
371
+ return this.runSessionTransition(async () => {
372
+ if (this._session === void 0) return;
373
+ const wasConnected = this._session != null;
374
+ this._session = void 0;
375
+ this._onDidChange.fire();
376
+ if (await this.resolveSessionLocked({ createIfNeeded: false }) != null || !wasConnected) return;
377
+ this._onDidChange.fire();
378
+ this.didChangeConnection?.fire({
379
+ integration: this,
380
+ key: this.key,
381
+ reason: "disconnected"
382
+ });
383
+ await this.providerOnDisconnect?.();
302
384
  });
303
- await this.providerOnDisconnect?.();
304
385
  }
305
386
  skippedNonCloudReported = false;
306
- async syncCloudConnection(state, forceSync) {
387
+ /**
388
+ * Resolves the re-sync's failure, when its token fetch threw (cancellations included), so a caller can report it.
389
+ * Never rejects: the cloud sync fans this out over every integration and must not fail on one.
390
+ */
391
+ syncCloudConnection(state, forceSync) {
392
+ return this.runSessionTransition(() => this.syncCloudConnectionLocked(state, forceSync));
393
+ }
394
+ async syncCloudConnectionLocked(state, forceSync) {
307
395
  const scope = getScopedLogger();
308
396
  if (this._session?.cloud === false && state !== "connected") {
309
397
  if (this.id !== "github" && !this.skippedNonCloudReported) {
@@ -316,14 +404,13 @@ var IntegrationBase = class IntegrationBase {
316
404
  }
317
405
  return;
318
406
  }
407
+ let failure;
319
408
  switch (state) {
320
409
  case "connected": {
321
410
  const oldSession = this._session;
322
411
  let resyncing = false;
323
412
  if (forceSync) {
324
- await (await this.authenticationService.get(this.authProvider.id)).deleteSession(this.authProviderDescriptor);
325
413
  this.onStoredTokensReplaced();
326
- this._session = void 0;
327
414
  await this.ctx.storage.deleteWorkspace(this.connectedKey);
328
415
  resyncing = true;
329
416
  } else {
@@ -333,17 +420,24 @@ var IntegrationBase = class IntegrationBase {
333
420
  let newSession;
334
421
  let refetchFailed = false;
335
422
  try {
336
- newSession = await this.ensureSession({ sync: forceSync });
423
+ newSession = await this.resolveSessionLocked({
424
+ sync: forceSync,
425
+ refetch: resyncing
426
+ });
337
427
  } catch (ex) {
338
- if (!isCancellationError(ex)) scope?.error(ex);
339
428
  refetchFailed = true;
429
+ if (!isCancellationError(ex)) scope?.error(ex);
430
+ failure = toError(ex);
340
431
  }
341
432
  if (oldSession && newSession && newSession.accessToken !== oldSession.accessToken) this.resetRequestExceptionCount("all");
342
433
  if (resyncing && newSession == null && !refetchFailed) await (await this.authenticationService.get(this.authProvider.id)).deleteSession(this.authProviderDescriptor, { preserveConfigured: false });
343
434
  break;
344
435
  }
345
- case "disconnected": await this.disconnect({ silent: true });
436
+ case "disconnected": await this.disconnectLocked({ silent: true }, true);
346
437
  }
438
+ this._resyncs++;
439
+ this._lastResyncFailure = failure;
440
+ return failure;
347
441
  }
348
442
  /**
349
443
  * Credentials that passed {@link validateCredential} within the last minute. A probe already in flight is
@@ -507,24 +601,48 @@ var IntegrationBase = class IntegrationBase {
507
601
  }
508
602
  if (ex instanceof AuthenticationError && this._session?.cloud && !this.hasSessionSyncRequests()) {
509
603
  this.requestSessionSyncForUsecase(syncReqUsecase);
510
- this._session = {
511
- ...this._session,
512
- expiresAt: /* @__PURE__ */ new Date(Date.now() - 1)
513
- };
604
+ const refused = this._session.accessToken;
605
+ this.expireSessionIfToken(refused);
606
+ this.runSessionTransition(() => {
607
+ this.expireSessionIfToken(refused);
608
+ return Promise.resolve();
609
+ });
514
610
  return false;
515
611
  }
516
612
  if (!(ex instanceof AuthenticationError) && !(ex instanceof RequestClientError)) return false;
517
613
  this.trackRequestException(options);
518
614
  return true;
519
615
  }
616
+ expireSessionIfToken(accessToken) {
617
+ if (this._session?.accessToken !== accessToken || this.connectionExpired === true) return;
618
+ this._session = {
619
+ ...this._session,
620
+ expiresAt: /* @__PURE__ */ new Date(Date.now() - 1)
621
+ };
622
+ }
623
+ /** Counts completed cloud re-syncs, so a refresh queued behind one reuses its outcome rather than running another. */
624
+ _resyncs = 0;
625
+ _lastResyncFailure;
520
626
  missingExpirityReported = false;
521
- async refreshSessionIfExpired(scope) {
522
- if (this._session?.expiresAt != null && this._session.expiresAt < /* @__PURE__ */ new Date()) try {
523
- await this.syncCloudConnection("connected", true);
524
- } catch (ex) {
525
- scope?.error(ex);
526
- }
527
- else if (this._session?.expiresAt == null && this.id !== "github" && !this.missingExpirityReported) {
627
+ /**
628
+ * Resolves the refresh's failure while the session it could not replace is still unusable, so a read can report
629
+ * that instead of reading with an expired token or as a missing connection.
630
+ */
631
+ async refreshSessionIfExpired() {
632
+ if (this._session?.expiresAt != null && this._session.expiresAt < /* @__PURE__ */ new Date()) {
633
+ const resyncs = this._resyncs;
634
+ return this.runSessionTransition(async () => {
635
+ let failure;
636
+ if (this._resyncs !== resyncs) failure = this._lastResyncFailure;
637
+ else if (this._session != null && this.connectionExpired === true) try {
638
+ failure = await this.syncCloudConnectionLocked("connected", true);
639
+ } catch (ex) {
640
+ failure = toError(ex);
641
+ }
642
+ if (this._session == null || this.connectionExpired !== true) return void 0;
643
+ return failure ?? /* @__PURE__ */ new Error(`The ${this.name} session could not be refreshed`);
644
+ });
645
+ } else if (this._session?.expiresAt == null && this.id !== "github" && !this.missingExpirityReported) {
528
646
  this.ctx.hooks?.session?.onRefreshSkipped?.({
529
647
  id: this.id,
530
648
  reason: "missing-expiry",
@@ -541,12 +659,37 @@ var IntegrationBase = class IntegrationBase {
541
659
  }
542
660
  }
543
661
  async isConnected() {
544
- return await this.getSession("integrations") != null;
662
+ const session = this._session === void 0 ? await this.ensureSession({
663
+ createIfNeeded: false,
664
+ source: "integrations"
665
+ }) : this._session;
666
+ return session != null && this.isSessionForIntegrationHost(session);
545
667
  }
546
668
  async ensureSession(options) {
669
+ if (options.createIfNeeded || options.forceNewSession) {
670
+ if (!await this.signIn(options)) return this._session ?? void 0;
671
+ return this.runSessionTransition(() => this.resolveSessionLocked({ createIfNeeded: false }));
672
+ }
673
+ return this.runSessionTransition(() => this.resolveSessionLocked(options));
674
+ }
675
+ /**
676
+ * Asks the auth provider for a session interactively, storing what it gets; whether one came back. A declined
677
+ * consent or any other failure is not one, and never throws: it is resolved, like any read, from storage.
678
+ */
679
+ async signIn(options) {
680
+ if (this.ctx.config.isIntegrationsEnabled?.() === false) return false;
681
+ await this.ctx.storage.deleteWorkspace(this.connectedKey);
682
+ try {
683
+ return await (await this.authenticationService.get(this.authProvider.id)).getSession(this.authProviderDescriptor, options) != null;
684
+ } catch {
685
+ return false;
686
+ }
687
+ }
688
+ /** Resolves and publishes the session. Only ever runs inside a transition (see {@link runSessionTransition}). */
689
+ async resolveSessionLocked(options) {
547
690
  const scope = getScopedLogger();
548
- const { createIfNeeded, forceNewSession, source, sync } = options;
549
- if (this._session != null) {
691
+ const { createIfNeeded, source, sync, refetch } = options;
692
+ if (this._session != null && !refetch) {
550
693
  if (this.isSessionForIntegrationHost(this._session)) return this._session;
551
694
  this._session = null;
552
695
  }
@@ -557,10 +700,10 @@ var IntegrationBase = class IntegrationBase {
557
700
  try {
558
701
  session = await (await this.authenticationService.get(this.authProvider.id)).getSession(this.authProviderDescriptor, sync ? {
559
702
  sync,
703
+ refetch,
560
704
  source
561
705
  } : {
562
706
  createIfNeeded,
563
- forceNewSession,
564
707
  source
565
708
  });
566
709
  if (session?.expiresAt != null && session.expiresAt < /* @__PURE__ */ new Date()) session = null;
@@ -611,7 +754,7 @@ var IntegrationBase = class IntegrationBase {
611
754
  async searchMyIssuesResult(resources, cancellation, connectionId) {
612
755
  const scope = getScopedLogger();
613
756
  const session = await this.resolveReadSession(connectionId, scope);
614
- if (session == null) return void 0;
757
+ if (session == null || isReadSessionFailure(session)) return session && { error: session.error };
615
758
  const start = performance.now();
616
759
  try {
617
760
  const issues = await this.searchProviderMyIssues(session, resources != null ? Array.isArray(resources) ? resources : [resources] : void 0, cancellation);
@@ -650,7 +793,7 @@ var IntegrationBase = class IntegrationBase {
650
793
  async searchMyIssuesWithTruncationResult(resources, cancellation, connectionId, options) {
651
794
  const scope = getScopedLogger();
652
795
  const session = await this.resolveReadSession(connectionId, scope);
653
- if (session == null) return void 0;
796
+ if (session == null || isReadSessionFailure(session)) return session && { error: session.error };
654
797
  try {
655
798
  const result = await this.searchProviderMyIssuesWithTruncation(session, resources != null ? Array.isArray(resources) ? resources : [resources] : void 0, cancellation, options);
656
799
  await this.confirmScopedAuthFailures(session, result?.metadata);
@@ -666,8 +809,7 @@ var IntegrationBase = class IntegrationBase {
666
809
  }
667
810
  async getLinkedIssueOrPullRequest(resource, link, options) {
668
811
  const scope = getScopedLogger();
669
- if (!(this.maybeConnected ?? await this.isConnected())) return void 0;
670
- await this.refreshSessionIfExpired(scope);
812
+ if (await this.prepareSessionLookup() != null) return void 0;
671
813
  return this.ctx.cache.getIssueOrPullRequest(link.key, options?.type, resource, this, () => ({ value: (async () => {
672
814
  try {
673
815
  const result = await this.getProviderLinkedIssueOrPullRequest(this._session, resource, link, options?.type);
@@ -690,7 +832,7 @@ var IntegrationBase = class IntegrationBase {
690
832
  const { connectionId: requestedConnectionId, expiryOverride } = options ?? {};
691
833
  const connectionId = requestedConnectionId || void 0;
692
834
  const session = await this.resolveReadSession(connectionId, scope);
693
- if (session == null) return void 0;
835
+ if (session == null || isReadSessionFailure(session)) return session && { error: session.error };
694
836
  try {
695
837
  return { value: await getCachedIssue({
696
838
  cache: this.ctx.cache,
@@ -724,7 +866,7 @@ var IntegrationBase = class IntegrationBase {
724
866
  const scope = getScopedLogger();
725
867
  const { connectionId: requestedConnectionId, expiryOverride, ...opts } = options ?? {};
726
868
  const connectionId = requestedConnectionId || void 0;
727
- const session = await this.resolveReadSession(connectionId, scope);
869
+ const session = await this.resolveReadSessionOrThrow(connectionId, scope);
728
870
  if (session == null) return void 0;
729
871
  const sessionFingerprint = this.getSessionFingerprint(session);
730
872
  return await this.ctx.cache.getCurrentAccount(this, (cacheable) => ({ value: (async () => {
@@ -765,8 +907,12 @@ var IntegrationBase = class IntegrationBase {
765
907
  }
766
908
  async getPullRequest(resource, id, options) {
767
909
  const scope = getScopedLogger();
768
- if (!(this.maybeConnected ?? await this.isConnected())) return void 0;
769
- await this.refreshSessionIfExpired(scope);
910
+ const refreshFailure = await this.prepareSessionLookup();
911
+ if (refreshFailure === "unconnected") return void 0;
912
+ if (refreshFailure != null) {
913
+ if (options?.throwOnError) throw refreshFailure;
914
+ return;
915
+ }
770
916
  return await this.ctx.cache.getPullRequest(id, resource, this, (cacheable) => ({ value: (async () => {
771
917
  try {
772
918
  const result = await this.getProviderPullRequest?.(this._session, resource, id);
@@ -794,6 +940,6 @@ __decorate([trace()], IntegrationBase.prototype, "getLinkedIssueOrPullRequest",
794
940
  __decorate([trace()], IntegrationBase.prototype, "getIssueResultCore", null);
795
941
  __decorate([trace()], IntegrationBase.prototype, "getPullRequest", null);
796
942
  //#endregion
797
- export { IntegrationBase, issueEtagIncludes, pullRequestEtagIncludes, pullRequestRevision };
943
+ export { IntegrationBase, ReadSessionFailure, isReadSessionFailure, issueEtagIncludes, pullRequestEtagIncludes, pullRequestRevision };
798
944
 
799
945
  //# sourceMappingURL=integration.js.map