* Providers -> integrations (wip) * Updates folder structure of provider and auth logic * Adds basic providers api integration Adds filtering support on inputs Improves types and adds getCurrentUser support Updates getCurrentUser Fns Adds GetReposFn for Azure Reorganizes types and constants Adds providers service Uses correct filter property for Bitbucket PRs Switches to logged errors Adds filter compatibility check Adds paging and per-repo PR/Issue support Uses PagedResult for paging Updates dependencies Updates dependency Reorganizes and moves provider service logic to providerIntegration Updates missing provider mappings, ProviderId usage Updates provider api dependency Adds enterprise domain passing and GLSH Uses the correct base api urls Updates dependency * Refactors provider integrations (wip) - Removes `RichRemoteProvider` in favor of `ProviderIntegration` - Implements GitLab integration - Fixes caching - Fixes GitHub authentication * Avoids allocating integration auth before required * Unifies remote provider service into integrations * Fixes GitLab API base url * Moves fallback for getting vscode session to integration auth service --------- Co-authored-by: Eric Amodio <eamodio@gmail.com>main
@ -1,49 +0,0 @@ | |||
import type { Event } from 'vscode'; | |||
import { EventEmitter } from 'vscode'; | |||
import type { Container } from '../../container'; | |||
export interface ConnectionStateChangeEvent { | |||
key: string; | |||
reason: 'connected' | 'disconnected'; | |||
} | |||
export class RichRemoteProviderService { | |||
private readonly _onDidChangeConnectionState = new EventEmitter<ConnectionStateChangeEvent>(); | |||
get onDidChangeConnectionState(): Event<ConnectionStateChangeEvent> { | |||
return this._onDidChangeConnectionState.event; | |||
} | |||
private readonly _onAfterDidChangeConnectionState = new EventEmitter<ConnectionStateChangeEvent>(); | |||
get onAfterDidChangeConnectionState(): Event<ConnectionStateChangeEvent> { | |||
return this._onAfterDidChangeConnectionState.event; | |||
} | |||
private readonly _connectedCache = new Set<string>(); | |||
constructor(private readonly container: Container) {} | |||
connected(key: string): void { | |||
// Only fire events if the key is being connected for the first time | |||
if (this._connectedCache.has(key)) return; | |||
this._connectedCache.add(key); | |||
this.container.telemetry.sendEvent('remoteProviders/connected', { 'remoteProviders.key': key }); | |||
this._onDidChangeConnectionState.fire({ key: key, reason: 'connected' }); | |||
setTimeout(() => this._onAfterDidChangeConnectionState.fire({ key: key, reason: 'connected' }), 250); | |||
} | |||
disconnected(key: string): void { | |||
// Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe | |||
// if (!_connectedCache.has(key)) return; | |||
this._connectedCache.delete(key); | |||
this.container.telemetry.sendEvent('remoteProviders/disconnected', { 'remoteProviders.key': key }); | |||
this._onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }); | |||
setTimeout(() => this._onAfterDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }), 250); | |||
} | |||
isConnected(key?: string): boolean { | |||
return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); | |||
} | |||
} |
@ -1,621 +0,0 @@ | |||
/* eslint-disable @typescript-eslint/no-confusing-void-expression */ | |||
import type { | |||
AuthenticationSession, | |||
AuthenticationSessionsChangeEvent, | |||
CancellationToken, | |||
Event, | |||
MessageItem, | |||
} from 'vscode'; | |||
import { authentication, CancellationError, Disposable, EventEmitter, window } from 'vscode'; | |||
import { wrapForForcedInsecureSSL } from '@env/fetch'; | |||
import { isWeb } from '@env/platform'; | |||
import type { Container } from '../../container'; | |||
import { AuthenticationError, ProviderRequestClientError } from '../../errors'; | |||
import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; | |||
import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../../plus/gk/account/subscription'; | |||
import type { IntegrationAuthenticationSessionDescriptor } from '../../plus/integrationAuthentication'; | |||
import { configuration } from '../../system/configuration'; | |||
import { gate } from '../../system/decorators/gate'; | |||
import { debug, log, logName } from '../../system/decorators/log'; | |||
import { Logger } from '../../system/logger'; | |||
import type { LogScope } from '../../system/logger.scope'; | |||
import { getLogScope } from '../../system/logger.scope'; | |||
import type { Account } from '../models/author'; | |||
import type { DefaultBranch } from '../models/defaultBranch'; | |||
import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; | |||
import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; | |||
import type { RepositoryMetadata } from '../models/repositoryMetadata'; | |||
import { RemoteProvider } from './remoteProvider'; | |||
// TODO@eamodio revisit how once authenticated, all remotes are always connected, even after a restart | |||
export type RepositoryDescriptor = Record<string, string>; | |||
@logName<RichRemoteProvider>((c, name) => `${name}(${c.remoteKey})`) | |||
export abstract class RichRemoteProvider<T extends RepositoryDescriptor = RepositoryDescriptor> | |||
extends RemoteProvider | |||
implements Disposable | |||
{ | |||
override readonly type: 'simple' | 'rich' = 'rich'; | |||
private readonly _onDidChange = new EventEmitter<void>(); | |||
get onDidChange(): Event<void> { | |||
return this._onDidChange.event; | |||
} | |||
private readonly _disposable: Disposable; | |||
constructor( | |||
protected readonly container: Container, | |||
domain: string, | |||
path: string, | |||
protocol?: string, | |||
name?: string, | |||
custom?: boolean, | |||
) { | |||
super(domain, path, protocol, name, custom); | |||
this._disposable = Disposable.from( | |||
configuration.onDidChange(e => { | |||
if (configuration.changed(e, 'remotes')) { | |||
this._ignoreSSLErrors.clear(); | |||
} | |||
}), | |||
// TODO@eamodio revisit how connections are linked or not | |||
container.richRemoteProviders.onDidChangeConnectionState(e => { | |||
if (e.key !== this.key) return; | |||
if (e.reason === 'disconnected') { | |||
void this.disconnect({ silent: true }); | |||
} else if (e.reason === 'connected') { | |||
void this.ensureSession(false); | |||
} | |||
}), | |||
authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), | |||
); | |||
container.context.subscriptions.push(this._disposable); | |||
// If we think we should be connected, try to | |||
if (this.shouldConnect) { | |||
void this.isConnected(); | |||
} | |||
} | |||
disposed = false; | |||
dispose() { | |||
this._disposable.dispose(); | |||
this.disposed = true; | |||
} | |||
abstract get apiBaseUrl(): string; | |||
protected abstract get authProvider(): { id: string; scopes: string[] }; | |||
protected get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { | |||
return { domain: this.domain, scopes: this.authProvider.scopes }; | |||
} | |||
private get key() { | |||
return this.custom ? `${this.name}:${this.domain}` : this.name; | |||
} | |||
private get connectedKey(): `connected:${string}` { | |||
return `connected:${this.key}`; | |||
} | |||
override get maybeConnected(): boolean | undefined { | |||
return this._session === undefined ? undefined : this._session !== null; | |||
} | |||
// This is a hack for now, since providers come and go with remotes | |||
get shouldConnect(): boolean { | |||
return this.container.richRemoteProviders.isConnected(this.key); | |||
} | |||
protected _session: AuthenticationSession | null | undefined; | |||
protected session() { | |||
if (this._session === undefined) { | |||
return this.ensureSession(false); | |||
} | |||
return this._session ?? undefined; | |||
} | |||
private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { | |||
if (e.provider.id === this.authProvider.id) { | |||
void this.ensureSession(false); | |||
} | |||
} | |||
@log() | |||
async connect(): Promise<boolean> { | |||
try { | |||
const session = await this.ensureSession(true); | |||
return Boolean(session); | |||
} catch (ex) { | |||
return false; | |||
} | |||
} | |||
@gate() | |||
@log() | |||
async disconnect(options?: { silent?: boolean; currentSessionOnly?: boolean }): Promise<void> { | |||
if (options?.currentSessionOnly && this._session === null) return; | |||
const connected = this._session != null; | |||
if (connected && !options?.silent) { | |||
if (options?.currentSessionOnly) { | |||
void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); | |||
} else { | |||
const disable = { title: 'Disable' }; | |||
const signout = { title: 'Disable & Sign Out' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
let result: MessageItem | undefined; | |||
if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { | |||
result = await window.showWarningMessage( | |||
`Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, | |||
{ modal: true }, | |||
disable, | |||
signout, | |||
cancel, | |||
); | |||
} else { | |||
result = await window.showWarningMessage( | |||
`Are you sure you want to disable the rich integration with ${this.name}?`, | |||
{ modal: true }, | |||
disable, | |||
cancel, | |||
); | |||
} | |||
if (result == null || result === cancel) return; | |||
if (result === signout) { | |||
void this.container.integrationAuthentication.deleteSession( | |||
this.authProvider.id, | |||
this.authProviderDescriptor, | |||
); | |||
} | |||
} | |||
} | |||
this.resetRequestExceptionCount(); | |||
this._session = null; | |||
if (connected) { | |||
// Don't store the disconnected flag if this only for this current VS Code session (will be re-connected on next restart) | |||
if (!options?.currentSessionOnly) { | |||
void this.container.storage.storeWorkspace(this.connectedKey, false); | |||
} | |||
this._onDidChange.fire(); | |||
if (!options?.silent && !options?.currentSessionOnly) { | |||
this.container.richRemoteProviders.disconnected(this.key); | |||
} | |||
} | |||
} | |||
@log() | |||
async reauthenticate(): Promise<void> { | |||
if (this._session === undefined) return; | |||
this._session = undefined; | |||
void (await this.ensureSession(true, true)); | |||
} | |||
private requestExceptionCount = 0; | |||
resetRequestExceptionCount() { | |||
this.requestExceptionCount = 0; | |||
} | |||
private handleProviderException<T>(ex: Error, scope: LogScope | undefined, defaultValue: T): T { | |||
if (ex instanceof CancellationError) return defaultValue; | |||
Logger.error(ex, scope); | |||
if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { | |||
this.trackRequestException(); | |||
} | |||
return defaultValue; | |||
} | |||
@debug() | |||
trackRequestException() { | |||
this.requestExceptionCount++; | |||
if (this.requestExceptionCount >= 5 && this._session !== null) { | |||
void this.disconnect({ currentSessionOnly: true }); | |||
} | |||
} | |||
@gate() | |||
@debug({ exit: true }) | |||
async isConnected(): Promise<boolean> { | |||
return (await this.session()) != null; | |||
} | |||
@gate() | |||
@debug() | |||
async getAccountForCommit( | |||
ref: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
try { | |||
const author = await this.getProviderAccountForCommit(this._session!, ref, options); | |||
this.resetRequestExceptionCount(); | |||
return author; | |||
} catch (ex) { | |||
return this.handleProviderException(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract getProviderAccountForCommit( | |||
session: AuthenticationSession, | |||
ref: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined>; | |||
@gate() | |||
@debug() | |||
async getAccountForEmail( | |||
email: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
try { | |||
const author = await this.getProviderAccountForEmail(this._session!, email, options); | |||
this.resetRequestExceptionCount(); | |||
return author; | |||
} catch (ex) { | |||
return this.handleProviderException(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract getProviderAccountForEmail( | |||
session: AuthenticationSession, | |||
email: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined>; | |||
@debug() | |||
async getDefaultBranch(): Promise<DefaultBranch | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const defaultBranch = this.container.cache.getRepositoryDefaultBranch(this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderDefaultBranch(this._session!); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<DefaultBranch | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return defaultBranch; | |||
} | |||
protected abstract getProviderDefaultBranch({ | |||
accessToken, | |||
}: AuthenticationSession): Promise<DefaultBranch | undefined>; | |||
private _ignoreSSLErrors = new Map<string, boolean | 'force'>(); | |||
getIgnoreSSLErrors(): boolean | 'force' { | |||
if (isWeb) return false; | |||
let ignoreSSLErrors = this._ignoreSSLErrors.get(this.id); | |||
if (ignoreSSLErrors === undefined) { | |||
const cfg = configuration | |||
.get('remotes') | |||
?.find(remote => remote.type.toLowerCase() === this.id && remote.domain === this.domain); | |||
ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; | |||
this._ignoreSSLErrors.set(this.id, ignoreSSLErrors); | |||
} | |||
return ignoreSSLErrors; | |||
} | |||
@debug() | |||
async getRepositoryMetadata(_cancellation?: CancellationToken): Promise<RepositoryMetadata | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const metadata = this.container.cache.getRepositoryMetadata(this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderRepositoryMetadata(this._session!); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<RepositoryMetadata | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return metadata; | |||
} | |||
protected abstract getProviderRepositoryMetadata({ | |||
accessToken, | |||
}: AuthenticationSession): Promise<RepositoryMetadata | undefined>; | |||
@debug() | |||
async getIssueOrPullRequest(id: string, repo: T | undefined): Promise<IssueOrPullRequest | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const issueOrPR = this.container.cache.getIssueOrPullRequest(id, repo, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderIssueOrPullRequest(this._session!, id, repo); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<IssueOrPullRequest | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return issueOrPR; | |||
} | |||
protected abstract getProviderIssueOrPullRequest( | |||
session: AuthenticationSession, | |||
id: string, | |||
repo: T | undefined, | |||
): Promise<IssueOrPullRequest | undefined>; | |||
@debug() | |||
async getPullRequestForBranch( | |||
branch: string, | |||
options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const pr = this.container.cache.getPullRequestForBranch(branch, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderPullRequestForBranch(this._session!, branch, options); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<PullRequest | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return pr; | |||
} | |||
protected abstract getProviderPullRequestForBranch( | |||
session: AuthenticationSession, | |||
branch: string, | |||
options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined>; | |||
@debug() | |||
async getPullRequestForCommit(ref: string): Promise<PullRequest | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const pr = this.container.cache.getPullRequestForSha(ref, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderPullRequestForCommit(this._session!, ref); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<PullRequest | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return pr; | |||
} | |||
protected abstract getProviderPullRequestForCommit( | |||
session: AuthenticationSession, | |||
ref: string, | |||
): Promise<PullRequest | undefined>; | |||
@gate() | |||
@debug() | |||
async searchMyIssues(): Promise<SearchedIssue[] | undefined> { | |||
const scope = getLogScope(); | |||
try { | |||
const issues = await this.searchProviderMyIssues(this._session!); | |||
this.resetRequestExceptionCount(); | |||
return issues; | |||
} catch (ex) { | |||
return this.handleProviderException(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract searchProviderMyIssues(session: AuthenticationSession): Promise<SearchedIssue[] | undefined>; | |||
@gate() | |||
@debug() | |||
async searchMyPullRequests(): Promise<SearchedPullRequest[] | undefined> { | |||
const scope = getLogScope(); | |||
try { | |||
const pullRequests = await this.searchProviderMyPullRequests(this._session!); | |||
this.resetRequestExceptionCount(); | |||
return pullRequests; | |||
} catch (ex) { | |||
return this.handleProviderException(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract searchProviderMyPullRequests( | |||
session: AuthenticationSession, | |||
): Promise<SearchedPullRequest[] | undefined>; | |||
@gate() | |||
private async ensureSession( | |||
createIfNeeded: boolean, | |||
forceNewSession: boolean = false, | |||
): Promise<AuthenticationSession | undefined> { | |||
if (this._session != null) return this._session; | |||
if (!configuration.get('integrations.enabled')) return undefined; | |||
if (createIfNeeded) { | |||
await this.container.storage.deleteWorkspace(this.connectedKey); | |||
} else if (this.container.storage.getWorkspace(this.connectedKey) === false) { | |||
return undefined; | |||
} | |||
let session: AuthenticationSession | undefined | null; | |||
try { | |||
if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { | |||
session = await this.container.integrationAuthentication.getSession( | |||
this.authProvider.id, | |||
this.authProviderDescriptor, | |||
{ createIfNeeded: createIfNeeded, forceNewSession: forceNewSession }, | |||
); | |||
} else { | |||
session = await wrapForForcedInsecureSSL(this.getIgnoreSSLErrors(), () => | |||
authentication.getSession(this.authProvider.id, this.authProvider.scopes, { | |||
createIfNone: forceNewSession ? undefined : createIfNeeded, | |||
silent: !createIfNeeded && !forceNewSession ? true : undefined, | |||
forceNewSession: forceNewSession ? true : undefined, | |||
}), | |||
); | |||
} | |||
} catch (ex) { | |||
await this.container.storage.deleteWorkspace(this.connectedKey); | |||
if (ex instanceof Error && ex.message.includes('User did not consent')) { | |||
return undefined; | |||
} | |||
session = null; | |||
} | |||
if (session === undefined && !createIfNeeded) { | |||
await this.container.storage.deleteWorkspace(this.connectedKey); | |||
} | |||
this._session = session ?? null; | |||
this.resetRequestExceptionCount(); | |||
if (session != null) { | |||
await this.container.storage.storeWorkspace(this.connectedKey, true); | |||
queueMicrotask(() => { | |||
this._onDidChange.fire(); | |||
this.container.richRemoteProviders.connected(this.key); | |||
}); | |||
} | |||
return session ?? undefined; | |||
} | |||
} | |||
export async function ensurePaidPlan(providerName: string, container: Container): Promise<boolean> { | |||
const title = `Connecting to a ${providerName} instance for rich integration features requires a trial or paid plan.`; | |||
while (true) { | |||
const subscription = await container.subscription.getSubscription(); | |||
if (subscription.account?.verified === false) { | |||
const resend = { title: 'Resend Verification' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nYou must verify your email before you can continue.`, | |||
{ modal: true }, | |||
resend, | |||
cancel, | |||
); | |||
if (result === resend) { | |||
if (await container.subscription.resendVerification()) { | |||
continue; | |||
} | |||
} | |||
return false; | |||
} | |||
const plan = subscription.plan.effective.id; | |||
if (isSubscriptionPaidPlan(plan)) break; | |||
if (subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { | |||
const startTrial = { title: 'Preview Pro' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nDo you want to preview ✨ features for 3 days?`, | |||
{ modal: true }, | |||
startTrial, | |||
cancel, | |||
); | |||
if (result !== startTrial) return false; | |||
void container.subscription.startPreviewTrial(); | |||
break; | |||
} else if (subscription.account == null) { | |||
const signIn = { title: 'Start Free GitKraken Trial' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nDo you want to continue to use ✨ features on privately hosted repos, free for an additional 7 days?`, | |||
{ modal: true }, | |||
signIn, | |||
cancel, | |||
); | |||
if (result === signIn) { | |||
if (await container.subscription.loginOrSignUp()) { | |||
continue; | |||
} | |||
} | |||
} else { | |||
const upgrade = { title: 'Upgrade to Pro' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nDo you want to continue to use ✨ features on privately hosted repos?`, | |||
{ modal: true }, | |||
upgrade, | |||
cancel, | |||
); | |||
if (result === upgrade) { | |||
void container.subscription.purchase(); | |||
} | |||
} | |||
return false; | |||
} | |||
return true; | |||
} |
@ -1,116 +0,0 @@ | |||
import type { AuthenticationSession, Disposable } from 'vscode'; | |||
import type { Container } from '../container'; | |||
import { debug } from '../system/decorators/log'; | |||
interface StoredSession { | |||
id: string; | |||
accessToken: string; | |||
account?: { | |||
label?: string; | |||
displayName?: string; | |||
id: string; | |||
}; | |||
scopes: string[]; | |||
} | |||
export interface IntegrationAuthenticationSessionDescriptor { | |||
domain: string; | |||
scopes: string[]; | |||
[key: string]: unknown; | |||
} | |||
export interface IntegrationAuthenticationProvider { | |||
getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string; | |||
createSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<AuthenticationSession | undefined>; | |||
} | |||
export class IntegrationAuthenticationService implements Disposable { | |||
private readonly providers = new Map<string, IntegrationAuthenticationProvider>(); | |||
constructor(private readonly container: Container) {} | |||
dispose() { | |||
this.providers.clear(); | |||
} | |||
registerProvider(providerId: string, provider: IntegrationAuthenticationProvider): Disposable { | |||
if (this.providers.has(providerId)) throw new Error(`Provider with id ${providerId} already registered`); | |||
this.providers.set(providerId, provider); | |||
return { | |||
dispose: () => this.providers.delete(providerId), | |||
}; | |||
} | |||
hasProvider(providerId: string): boolean { | |||
return this.providers.has(providerId); | |||
} | |||
@debug() | |||
async createSession( | |||
providerId: string, | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
): Promise<AuthenticationSession | undefined> { | |||
const provider = this.providers.get(providerId); | |||
if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); | |||
const session = await provider?.createSession(descriptor); | |||
if (session == null) return undefined; | |||
const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); | |||
await this.container.storage.storeSecret(key, JSON.stringify(session)); | |||
return session; | |||
} | |||
@debug() | |||
async getSession( | |||
providerId: string, | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, | |||
): Promise<AuthenticationSession | undefined> { | |||
const provider = this.providers.get(providerId); | |||
if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); | |||
const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); | |||
if (options?.forceNewSession) { | |||
await this.container.storage.deleteSecret(key); | |||
} | |||
let storedSession: StoredSession | undefined; | |||
try { | |||
const sessionJSON = await this.container.storage.getSecret(key); | |||
if (sessionJSON) { | |||
storedSession = JSON.parse(sessionJSON); | |||
} | |||
} catch (ex) { | |||
try { | |||
await this.container.storage.deleteSecret(key); | |||
} catch {} | |||
if (!options?.createIfNeeded) { | |||
throw ex; | |||
} | |||
} | |||
if (options?.createIfNeeded && storedSession == null) { | |||
return this.createSession(providerId, descriptor); | |||
} | |||
return storedSession as AuthenticationSession | undefined; | |||
} | |||
@debug() | |||
async deleteSession(providerId: string, descriptor?: IntegrationAuthenticationSessionDescriptor) { | |||
const provider = this.providers.get(providerId); | |||
if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); | |||
const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); | |||
await this.container.storage.deleteSecret(key); | |||
} | |||
private getSecretKey(providerId: string, id: string): `gitlens.integration.auth:${string}` { | |||
return `gitlens.integration.auth:${providerId}|${id}`; | |||
} | |||
} |
@ -0,0 +1,123 @@ | |||
import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; | |||
import { env, ThemeIcon, Uri, window } from 'vscode'; | |||
import { base64 } from '../../../system/string'; | |||
import { supportedInVSCodeVersion } from '../../../system/utils'; | |||
import type { | |||
IntegrationAuthenticationProvider, | |||
IntegrationAuthenticationSessionDescriptor, | |||
} from './integrationAuthentication'; | |||
export class AzureDevOpsAuthenticationProvider implements IntegrationAuthenticationProvider { | |||
getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { | |||
return descriptor?.domain ?? ''; | |||
} | |||
async createSession( | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
): Promise<AuthenticationSession | undefined> { | |||
let azureOrganization: string | undefined = descriptor?.organization as string | undefined; | |||
if (!azureOrganization) { | |||
const orgInput = window.createInputBox(); | |||
orgInput.ignoreFocusOut = true; | |||
const orgInputDisposables: Disposable[] = []; | |||
try { | |||
azureOrganization = await new Promise<string | undefined>(resolve => { | |||
orgInputDisposables.push( | |||
orgInput.onDidHide(() => resolve(undefined)), | |||
orgInput.onDidChangeValue(() => (orgInput.validationMessage = undefined)), | |||
orgInput.onDidAccept(() => { | |||
const value = orgInput.value.trim(); | |||
if (!value) { | |||
orgInput.validationMessage = 'An organization is required'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
); | |||
orgInput.title = `Azure DevOps Authentication${ | |||
descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' | |||
}`; | |||
orgInput.placeholder = 'Organization'; | |||
orgInput.prompt = 'Enter your Azure DevOps organization'; | |||
orgInput.show(); | |||
}); | |||
} finally { | |||
orgInput.dispose(); | |||
orgInputDisposables.forEach(d => void d.dispose()); | |||
} | |||
} | |||
if (!azureOrganization) return undefined; | |||
const tokenInput = window.createInputBox(); | |||
tokenInput.ignoreFocusOut = true; | |||
const disposables: Disposable[] = []; | |||
let token; | |||
try { | |||
const infoButton: QuickInputButton = { | |||
iconPath: new ThemeIcon(`link-external`), | |||
tooltip: 'Open the Azure DevOps Access Tokens Page', | |||
}; | |||
token = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
tokenInput.onDidHide(() => resolve(undefined)), | |||
tokenInput.onDidChangeValue(() => (tokenInput.validationMessage = undefined)), | |||
tokenInput.onDidAccept(() => { | |||
const value = tokenInput.value.trim(); | |||
if (!value) { | |||
tokenInput.validationMessage = 'A personal access token is required'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
tokenInput.onDidTriggerButton(e => { | |||
if (e === infoButton) { | |||
void env.openExternal( | |||
Uri.parse( | |||
`https://${ | |||
descriptor?.domain ?? 'dev.azure.com' | |||
}/${azureOrganization}/_usersSettings/tokens`, | |||
), | |||
); | |||
} | |||
}), | |||
); | |||
tokenInput.password = true; | |||
tokenInput.title = `Azure DevOps Authentication${ | |||
descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' | |||
}`; | |||
tokenInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; | |||
tokenInput.prompt = supportedInVSCodeVersion('input-prompt-links') | |||
? `Paste your [Azure DevOps Personal Access Token](https://${ | |||
descriptor?.domain ?? 'dev.azure.com' | |||
}/${azureOrganization}/_usersSettings/tokens "Get your Azure DevOps Access Token")` | |||
: 'Paste your Azure DevOps Personal Access Token'; | |||
tokenInput.buttons = [infoButton]; | |||
tokenInput.show(); | |||
}); | |||
} finally { | |||
tokenInput.dispose(); | |||
disposables.forEach(d => void d.dispose()); | |||
} | |||
if (!token) return undefined; | |||
return { | |||
id: this.getSessionId(descriptor), | |||
accessToken: base64(`:${token}`), | |||
scopes: descriptor?.scopes ?? [], | |||
account: { | |||
id: '', | |||
label: '', | |||
}, | |||
}; | |||
} | |||
} |
@ -0,0 +1,137 @@ | |||
import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; | |||
import { env, ThemeIcon, Uri, window } from 'vscode'; | |||
import { base64 } from '../../../system/string'; | |||
import { supportedInVSCodeVersion } from '../../../system/utils'; | |||
import type { | |||
IntegrationAuthenticationProvider, | |||
IntegrationAuthenticationSessionDescriptor, | |||
} from './integrationAuthentication'; | |||
export class BitbucketAuthenticationProvider implements IntegrationAuthenticationProvider { | |||
getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { | |||
return descriptor?.domain ?? ''; | |||
} | |||
async createSession( | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
): Promise<AuthenticationSession | undefined> { | |||
let bitbucketUsername: string | undefined = descriptor?.username as string | undefined; | |||
if (!bitbucketUsername) { | |||
const infoButton: QuickInputButton = { | |||
iconPath: new ThemeIcon(`link-external`), | |||
tooltip: 'Open the Bitbucket Settings Page', | |||
}; | |||
const usernameInput = window.createInputBox(); | |||
usernameInput.ignoreFocusOut = true; | |||
const usernameInputDisposables: Disposable[] = []; | |||
try { | |||
bitbucketUsername = await new Promise<string | undefined>(resolve => { | |||
usernameInputDisposables.push( | |||
usernameInput.onDidHide(() => resolve(undefined)), | |||
usernameInput.onDidChangeValue(() => (usernameInput.validationMessage = undefined)), | |||
usernameInput.onDidAccept(() => { | |||
const value = usernameInput.value.trim(); | |||
if (!value) { | |||
usernameInput.validationMessage = 'A Bitbucket username is required'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
usernameInput.onDidTriggerButton(e => { | |||
if (e === infoButton) { | |||
void env.openExternal( | |||
Uri.parse(`https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/`), | |||
); | |||
} | |||
}), | |||
); | |||
usernameInput.title = `Bitbucket Authentication${ | |||
descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' | |||
}`; | |||
usernameInput.placeholder = 'Username'; | |||
usernameInput.prompt = supportedInVSCodeVersion('input-prompt-links') | |||
? `Enter your [Bitbucket Username](https://${ | |||
descriptor?.domain ?? 'bitbucket.org' | |||
}/account/settings/ "Get your Bitbucket App Password")` | |||
: 'Enter your Bitbucket Username'; | |||
usernameInput.show(); | |||
}); | |||
} finally { | |||
usernameInput.dispose(); | |||
usernameInputDisposables.forEach(d => void d.dispose()); | |||
} | |||
} | |||
if (!bitbucketUsername) return undefined; | |||
const appPasswordInput = window.createInputBox(); | |||
appPasswordInput.ignoreFocusOut = true; | |||
const disposables: Disposable[] = []; | |||
let appPassword; | |||
try { | |||
const infoButton: QuickInputButton = { | |||
iconPath: new ThemeIcon(`link-external`), | |||
tooltip: 'Open the Bitbucket App Passwords Page', | |||
}; | |||
appPassword = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
appPasswordInput.onDidHide(() => resolve(undefined)), | |||
appPasswordInput.onDidChangeValue(() => (appPasswordInput.validationMessage = undefined)), | |||
appPasswordInput.onDidAccept(() => { | |||
const value = appPasswordInput.value.trim(); | |||
if (!value) { | |||
appPasswordInput.validationMessage = 'An app password is required'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
appPasswordInput.onDidTriggerButton(e => { | |||
if (e === infoButton) { | |||
void env.openExternal( | |||
Uri.parse( | |||
`https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/app-passwords/`, | |||
), | |||
); | |||
} | |||
}), | |||
); | |||
appPasswordInput.password = true; | |||
appPasswordInput.title = `Bitbucket Authentication${ | |||
descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' | |||
}`; | |||
appPasswordInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; | |||
appPasswordInput.prompt = supportedInVSCodeVersion('input-prompt-links') | |||
? `Paste your [Bitbucket App Password](https://${ | |||
descriptor?.domain ?? 'bitbucket.org' | |||
}/account/settings/app-passwords/ "Get your Bitbucket App Password")` | |||
: 'Paste your Bitbucket App Password'; | |||
appPasswordInput.buttons = [infoButton]; | |||
appPasswordInput.show(); | |||
}); | |||
} finally { | |||
appPasswordInput.dispose(); | |||
disposables.forEach(d => void d.dispose()); | |||
} | |||
if (!appPassword) return undefined; | |||
return { | |||
id: this.getSessionId(descriptor), | |||
accessToken: base64(`${bitbucketUsername}:${appPassword}`), | |||
scopes: descriptor?.scopes ?? [], | |||
account: { | |||
id: '', | |||
label: '', | |||
}, | |||
}; | |||
} | |||
} |
@ -0,0 +1,81 @@ | |||
import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; | |||
import { env, ThemeIcon, Uri, window } from 'vscode'; | |||
import { supportedInVSCodeVersion } from '../../../system/utils'; | |||
import type { | |||
IntegrationAuthenticationProvider, | |||
IntegrationAuthenticationSessionDescriptor, | |||
} from './integrationAuthentication'; | |||
export class GitHubEnterpriseAuthenticationProvider implements IntegrationAuthenticationProvider { | |||
getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { | |||
return descriptor?.domain ?? ''; | |||
} | |||
async createSession( | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
): Promise<AuthenticationSession | undefined> { | |||
const input = window.createInputBox(); | |||
input.ignoreFocusOut = true; | |||
const disposables: Disposable[] = []; | |||
let token; | |||
try { | |||
const infoButton: QuickInputButton = { | |||
iconPath: new ThemeIcon(`link-external`), | |||
tooltip: 'Open the GitHub Access Tokens Page', | |||
}; | |||
token = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
input.onDidHide(() => resolve(undefined)), | |||
input.onDidChangeValue(() => (input.validationMessage = undefined)), | |||
input.onDidAccept(() => { | |||
const value = input.value.trim(); | |||
if (!value) { | |||
input.validationMessage = 'A personal access token is required'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
input.onDidTriggerButton(e => { | |||
if (e === infoButton) { | |||
void env.openExternal( | |||
Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), | |||
); | |||
} | |||
}), | |||
); | |||
input.password = true; | |||
input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; | |||
input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; | |||
input.prompt = supportedInVSCodeVersion('input-prompt-links') | |||
? `Paste your [GitHub Personal Access Token](https://${ | |||
descriptor?.domain ?? 'github.com' | |||
}/settings/tokens "Get your GitHub Access Token")` | |||
: 'Paste your GitHub Personal Access Token'; | |||
input.buttons = [infoButton]; | |||
input.show(); | |||
}); | |||
} finally { | |||
input.dispose(); | |||
disposables.forEach(d => void d.dispose()); | |||
} | |||
if (!token) return undefined; | |||
return { | |||
id: this.getSessionId(descriptor), | |||
accessToken: token, | |||
scopes: descriptor?.scopes ?? [], | |||
account: { | |||
id: '', | |||
label: '', | |||
}, | |||
}; | |||
} | |||
} |
@ -0,0 +1,82 @@ | |||
import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; | |||
import { env, ThemeIcon, Uri, window } from 'vscode'; | |||
import { supportedInVSCodeVersion } from '../../../system/utils'; | |||
import type { | |||
IntegrationAuthenticationProvider, | |||
IntegrationAuthenticationSessionDescriptor, | |||
} from './integrationAuthentication'; | |||
export class GitLabAuthenticationProvider implements IntegrationAuthenticationProvider { | |||
getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { | |||
return descriptor?.domain ?? ''; | |||
} | |||
async createSession( | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
): Promise<AuthenticationSession | undefined> { | |||
const input = window.createInputBox(); | |||
input.ignoreFocusOut = true; | |||
const disposables: Disposable[] = []; | |||
let token; | |||
try { | |||
const infoButton: QuickInputButton = { | |||
iconPath: new ThemeIcon(`link-external`), | |||
tooltip: 'Open the GitLab Access Tokens Page', | |||
}; | |||
token = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
input.onDidHide(() => resolve(undefined)), | |||
input.onDidChangeValue(() => (input.validationMessage = undefined)), | |||
input.onDidAccept(() => { | |||
const value = input.value.trim(); | |||
if (!value) { | |||
input.validationMessage = 'A personal access token is required'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
input.onDidTriggerButton(e => { | |||
if (e === infoButton) { | |||
void env.openExternal( | |||
Uri.parse( | |||
`https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, | |||
), | |||
); | |||
} | |||
}), | |||
); | |||
input.password = true; | |||
input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; | |||
input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; | |||
input.prompt = supportedInVSCodeVersion('input-prompt-links') | |||
? `Paste your [GitLab Personal Access Token](https://${ | |||
descriptor?.domain ?? 'gitlab.com' | |||
}/-/profile/personal_access_tokens "Get your GitLab Access Token")` | |||
: 'Paste your GitLab Personal Access Token'; | |||
input.buttons = [infoButton]; | |||
input.show(); | |||
}); | |||
} finally { | |||
input.dispose(); | |||
disposables.forEach(d => void d.dispose()); | |||
} | |||
if (!token) return undefined; | |||
return { | |||
id: this.getSessionId(descriptor), | |||
accessToken: token, | |||
scopes: descriptor?.scopes ?? [], | |||
account: { | |||
id: '', | |||
label: '', | |||
}, | |||
}; | |||
} | |||
} |
@ -0,0 +1,166 @@ | |||
import type { AuthenticationSession, Disposable } from 'vscode'; | |||
import { authentication } from 'vscode'; | |||
import { wrapForForcedInsecureSSL } from '@env/fetch'; | |||
import type { Container } from '../../../container'; | |||
import { debug } from '../../../system/decorators/log'; | |||
import { ProviderId } from '../providers/models'; | |||
import { AzureDevOpsAuthenticationProvider } from './azureDevOps'; | |||
import { BitbucketAuthenticationProvider } from './bitbucket'; | |||
import { GitHubEnterpriseAuthenticationProvider } from './github'; | |||
import { GitLabAuthenticationProvider } from './gitlab'; | |||
interface StoredSession { | |||
id: string; | |||
accessToken: string; | |||
account?: { | |||
label?: string; | |||
displayName?: string; | |||
id: string; | |||
}; | |||
scopes: string[]; | |||
} | |||
export interface IntegrationAuthenticationProviderDescriptor { | |||
id: ProviderId; | |||
scopes: string[]; | |||
} | |||
export interface IntegrationAuthenticationSessionDescriptor { | |||
domain: string; | |||
scopes: string[]; | |||
[key: string]: unknown; | |||
} | |||
export interface IntegrationAuthenticationProvider { | |||
getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string; | |||
createSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<AuthenticationSession | undefined>; | |||
} | |||
export class IntegrationAuthenticationService implements Disposable { | |||
private readonly providers = new Map<ProviderId, IntegrationAuthenticationProvider>(); | |||
constructor(private readonly container: Container) {} | |||
dispose() { | |||
this.providers.clear(); | |||
} | |||
@debug() | |||
async createSession( | |||
providerId: ProviderId, | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
): Promise<AuthenticationSession | undefined> { | |||
const provider = this.ensureProvider(providerId); | |||
const session = await provider.createSession(descriptor); | |||
if (session == null) return undefined; | |||
const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); | |||
await this.container.storage.storeSecret(key, JSON.stringify(session)); | |||
return session; | |||
} | |||
@debug() | |||
async getSession( | |||
providerId: ProviderId, | |||
descriptor?: IntegrationAuthenticationSessionDescriptor, | |||
options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, | |||
): Promise<AuthenticationSession | undefined> { | |||
if (this.supports(providerId)) { | |||
const provider = this.ensureProvider(providerId); | |||
const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); | |||
if (options?.forceNewSession) { | |||
await this.container.storage.deleteSecret(key); | |||
} | |||
let storedSession: StoredSession | undefined; | |||
try { | |||
const sessionJSON = await this.container.storage.getSecret(key); | |||
if (sessionJSON) { | |||
storedSession = JSON.parse(sessionJSON); | |||
} | |||
} catch (ex) { | |||
try { | |||
await this.container.storage.deleteSecret(key); | |||
} catch {} | |||
if (!options?.createIfNeeded) { | |||
throw ex; | |||
} | |||
} | |||
if (options?.createIfNeeded && storedSession == null) { | |||
return this.createSession(providerId, descriptor); | |||
} | |||
return storedSession as AuthenticationSession | undefined; | |||
} | |||
if (descriptor == null) return undefined; | |||
const { createIfNeeded, forceNewSession } = options ?? {}; | |||
return wrapForForcedInsecureSSL( | |||
this.container.integrations.ignoreSSLErrors({ id: providerId, domain: descriptor?.domain }), | |||
() => | |||
authentication.getSession(providerId, descriptor.scopes, { | |||
createIfNone: forceNewSession ? undefined : createIfNeeded, | |||
silent: !createIfNeeded && !forceNewSession ? true : undefined, | |||
forceNewSession: forceNewSession ? true : undefined, | |||
}), | |||
); | |||
} | |||
@debug() | |||
async deleteSession(providerId: ProviderId, descriptor?: IntegrationAuthenticationSessionDescriptor) { | |||
const provider = this.ensureProvider(providerId); | |||
const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); | |||
await this.container.storage.deleteSecret(key); | |||
} | |||
supports(providerId: string): boolean { | |||
switch (providerId) { | |||
case ProviderId.AzureDevOps: | |||
case ProviderId.Bitbucket: | |||
case ProviderId.GitHubEnterprise: | |||
case ProviderId.GitLab: | |||
case ProviderId.GitLabSelfHosted: | |||
return true; | |||
default: | |||
return false; | |||
} | |||
} | |||
private getSecretKey(providerId: string, id: string): `gitlens.integration.auth:${string}` { | |||
return `gitlens.integration.auth:${providerId}|${id}`; | |||
} | |||
private ensureProvider(providerId: ProviderId): IntegrationAuthenticationProvider { | |||
let provider = this.providers.get(providerId); | |||
if (provider == null) { | |||
switch (providerId) { | |||
case ProviderId.AzureDevOps: | |||
provider = new AzureDevOpsAuthenticationProvider(); | |||
break; | |||
case ProviderId.Bitbucket: | |||
provider = new BitbucketAuthenticationProvider(); | |||
break; | |||
case ProviderId.GitHubEnterprise: | |||
provider = new GitHubEnterpriseAuthenticationProvider(); | |||
break; | |||
case ProviderId.GitLab: | |||
case ProviderId.GitLabSelfHosted: | |||
provider = new GitLabAuthenticationProvider(); | |||
break; | |||
default: | |||
throw new Error(`Provider '${providerId}' is not supported`); | |||
} | |||
this.providers.set(providerId, provider); | |||
} | |||
return provider; | |||
} | |||
} |
@ -0,0 +1,177 @@ | |||
import type { AuthenticationSessionsChangeEvent, Event } from 'vscode'; | |||
import { authentication, Disposable, EventEmitter } from 'vscode'; | |||
import { isWeb } from '@env/platform'; | |||
import type { Container } from '../../container'; | |||
import type { SearchedIssue } from '../../git/models/issue'; | |||
import type { SearchedPullRequest } from '../../git/models/pullRequest'; | |||
import type { GitRemote } from '../../git/models/remote'; | |||
import type { RemoteProviderId } from '../../git/remotes/remoteProvider'; | |||
import { configuration } from '../../system/configuration'; | |||
import { debug } from '../../system/decorators/log'; | |||
import type { ProviderIntegration, ProviderKey, SupportedProviderIds } from './providerIntegration'; | |||
import { AzureDevOpsIntegration } from './providers/azureDevOps'; | |||
import { BitbucketIntegration } from './providers/bitbucket'; | |||
import { GitHubEnterpriseIntegration, GitHubIntegration } from './providers/github'; | |||
import { GitLabIntegration, GitLabSelfHostedIntegration } from './providers/gitlab'; | |||
import { ProviderId } from './providers/models'; | |||
import { ProvidersApi } from './providers/providersApi'; | |||
export interface ConnectionStateChangeEvent { | |||
key: string; | |||
reason: 'connected' | 'disconnected'; | |||
} | |||
export class IntegrationService implements Disposable { | |||
private readonly _onDidChangeConnectionState = new EventEmitter<ConnectionStateChangeEvent>(); | |||
get onDidChangeConnectionState(): Event<ConnectionStateChangeEvent> { | |||
return this._onDidChangeConnectionState.event; | |||
} | |||
private readonly _connectedCache = new Set<string>(); | |||
private readonly _disposable: Disposable; | |||
private _integrations = new Map<ProviderKey, ProviderIntegration>(); | |||
private _providersApi: ProvidersApi; | |||
constructor(private readonly container: Container) { | |||
this._providersApi = new ProvidersApi(container); | |||
this._disposable = Disposable.from( | |||
configuration.onDidChange(e => { | |||
if (configuration.changed(e, 'remotes')) { | |||
this._ignoreSSLErrors.clear(); | |||
} | |||
}), | |||
authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), | |||
); | |||
} | |||
dispose() { | |||
this._disposable?.dispose(); | |||
} | |||
private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { | |||
for (const provider of this._integrations.values()) { | |||
if (e.provider.id === provider.authProvider.id) { | |||
provider.refresh(); | |||
} | |||
} | |||
} | |||
connected(key: string): void { | |||
// Only fire events if the key is being connected for the first time | |||
if (this._connectedCache.has(key)) return; | |||
this._connectedCache.add(key); | |||
this.container.telemetry.sendEvent('remoteProviders/connected', { 'remoteProviders.key': key }); | |||
setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'connected' }), 250); | |||
} | |||
disconnected(key: string): void { | |||
// Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe | |||
// if (!_connectedCache.has(key)) return; | |||
this._connectedCache.delete(key); | |||
this.container.telemetry.sendEvent('remoteProviders/disconnected', { 'remoteProviders.key': key }); | |||
setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }), 250); | |||
} | |||
isConnected(key?: string): boolean { | |||
return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); | |||
} | |||
get(id: SupportedProviderIds, domain?: string): ProviderIntegration { | |||
const key: ProviderKey = `${id}|${domain}`; | |||
let provider = this._integrations.get(key); | |||
if (provider == null) { | |||
switch (id) { | |||
case ProviderId.GitHub: | |||
provider = new GitHubIntegration(this.container, this._providersApi); | |||
break; | |||
case ProviderId.GitHubEnterprise: | |||
if (domain == null) throw new Error(`Domain is required for '${id}' integration`); | |||
provider = new GitHubEnterpriseIntegration(this.container, this._providersApi, domain); | |||
break; | |||
case ProviderId.GitLab: | |||
provider = new GitLabIntegration(this.container, this._providersApi); | |||
break; | |||
case ProviderId.GitLabSelfHosted: | |||
if (domain == null) throw new Error(`Domain is required for '${id}' integration`); | |||
provider = new GitLabSelfHostedIntegration(this.container, this._providersApi, domain); | |||
break; | |||
case ProviderId.Bitbucket: | |||
provider = new BitbucketIntegration(this.container, this._providersApi); | |||
break; | |||
case ProviderId.AzureDevOps: | |||
provider = new AzureDevOpsIntegration(this.container, this._providersApi); | |||
break; | |||
default: | |||
throw new Error(`Provider '${id}' is not supported`); | |||
} | |||
this._integrations.set(key, provider); | |||
} | |||
return provider; | |||
} | |||
getByRemote(remote: GitRemote): ProviderIntegration | undefined { | |||
if (remote?.provider == null) return undefined; | |||
const id = convertRemoteIdToProviderId(remote.provider.id); | |||
return id != null ? this.get(id, remote.domain) : undefined; | |||
} | |||
@debug<IntegrationService['getMyIssues']>({ args: { 0: r => r.name } }) | |||
async getMyIssues(remote: GitRemote): Promise<SearchedIssue[] | undefined> { | |||
if (remote?.provider == null) return undefined; | |||
const provider = this.getByRemote(remote); | |||
return provider?.searchMyIssues(); | |||
} | |||
@debug<IntegrationService['getMyPullRequests']>({ args: { 0: r => r.name } }) | |||
async getMyPullRequests(remote: GitRemote): Promise<SearchedPullRequest[] | undefined> { | |||
if (remote?.provider == null) return undefined; | |||
const provider = this.getByRemote(remote); | |||
return provider?.searchMyPullRequests(); | |||
} | |||
supports(remoteId: RemoteProviderId): boolean { | |||
return convertRemoteIdToProviderId(remoteId) != null; | |||
} | |||
private _ignoreSSLErrors = new Map<string, boolean | 'force'>(); | |||
ignoreSSLErrors( | |||
integration: ProviderIntegration | { id: SupportedProviderIds; domain: string }, | |||
): boolean | 'force' { | |||
if (isWeb) return false; | |||
let ignoreSSLErrors = this._ignoreSSLErrors.get(integration.id); | |||
if (ignoreSSLErrors === undefined) { | |||
const cfg = configuration | |||
.get('remotes') | |||
?.find(remote => remote.type.toLowerCase() === integration.id && remote.domain === integration.domain); | |||
ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; | |||
this._ignoreSSLErrors.set(integration.id, ignoreSSLErrors); | |||
} | |||
return ignoreSSLErrors; | |||
} | |||
} | |||
function convertRemoteIdToProviderId(remoteId: RemoteProviderId): SupportedProviderIds | undefined { | |||
switch (remoteId) { | |||
case 'azure-devops': | |||
return ProviderId.AzureDevOps; | |||
case 'bitbucket': | |||
case 'bitbucket-server': | |||
return ProviderId.Bitbucket; | |||
case 'github': | |||
return ProviderId.GitHub; | |||
case 'gitlab': | |||
return ProviderId.GitLab; | |||
default: | |||
return undefined; | |||
} | |||
} |
@ -0,0 +1,925 @@ | |||
import type { AuthenticationSession, CancellationToken, Event, MessageItem } from 'vscode'; | |||
import { CancellationError, EventEmitter, window } from 'vscode'; | |||
import type { Container } from '../../container'; | |||
import { AuthenticationError, ProviderRequestClientError } from '../../errors'; | |||
import type { PagedResult } from '../../git/gitProvider'; | |||
import type { Account } from '../../git/models/author'; | |||
import type { DefaultBranch } from '../../git/models/defaultBranch'; | |||
import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; | |||
import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../git/models/pullRequest'; | |||
import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; | |||
import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; | |||
import { configuration } from '../../system/configuration'; | |||
import { gate } from '../../system/decorators/gate'; | |||
import { debug, log } from '../../system/decorators/log'; | |||
import { Logger } from '../../system/logger'; | |||
import type { LogScope } from '../../system/logger.scope'; | |||
import { getLogScope } from '../../system/logger.scope'; | |||
import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../gk/account/subscription'; | |||
import type { | |||
IntegrationAuthenticationProviderDescriptor, | |||
IntegrationAuthenticationSessionDescriptor, | |||
} from './authentication/integrationAuthentication'; | |||
import type { | |||
GetIssuesOptions, | |||
GetPullRequestsOptions, | |||
PagedProjectInput, | |||
PagedRepoInput, | |||
ProviderAccount, | |||
ProviderIssue, | |||
ProviderPullRequest, | |||
ProviderRepoInput, | |||
ProviderReposInput, | |||
} from './providers/models'; | |||
import { IssueFilter, PagingMode, ProviderId, PullRequestFilter } from './providers/models'; | |||
import type { ProvidersApi } from './providers/providersApi'; | |||
// TODO@eamodio revisit how once authenticated, all remotes are always connected, even after a restart | |||
export type SupportedProviderIds = ProviderId; | |||
export type ProviderKey = `${SupportedProviderIds}|${string}`; | |||
export type RepositoryDescriptor = { key: string } & Record<string, unknown>; | |||
export abstract class ProviderIntegration<T extends RepositoryDescriptor = RepositoryDescriptor> { | |||
private readonly _onDidChange = new EventEmitter<void>(); | |||
get onDidChange(): Event<void> { | |||
return this._onDidChange.event; | |||
} | |||
constructor( | |||
protected readonly container: Container, | |||
protected readonly api: ProvidersApi, | |||
) {} | |||
abstract get authProvider(): IntegrationAuthenticationProviderDescriptor; | |||
abstract get id(): SupportedProviderIds; | |||
abstract get name(): string; | |||
abstract get domain(): string; | |||
protected get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { | |||
return { domain: this.domain, scopes: this.authProvider.scopes }; | |||
} | |||
get icon(): string { | |||
return this.id; | |||
} | |||
protected get key(): `${SupportedProviderIds}` | `${SupportedProviderIds}:${string}` { | |||
return this.id; | |||
} | |||
private get connectedKey(): `connected:${ProviderIntegration['key']}` { | |||
return `connected:${this.key}`; | |||
} | |||
get maybeConnected(): boolean | undefined { | |||
return this._session === undefined ? undefined : this._session !== null; | |||
} | |||
protected _session: AuthenticationSession | null | undefined; | |||
protected session() { | |||
if (this._session === undefined) { | |||
return this.ensureSession(false); | |||
} | |||
return this._session ?? undefined; | |||
} | |||
@log() | |||
async connect(): Promise<boolean> { | |||
try { | |||
const session = await this.ensureSession(true); | |||
return Boolean(session); | |||
} catch (ex) { | |||
return false; | |||
} | |||
} | |||
@gate() | |||
@log() | |||
async disconnect(options?: { silent?: boolean; currentSessionOnly?: boolean }): Promise<void> { | |||
if (options?.currentSessionOnly && this._session === null) return; | |||
const connected = this._session != null; | |||
if (connected && !options?.silent) { | |||
if (options?.currentSessionOnly) { | |||
void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); | |||
} else { | |||
const disable = { title: 'Disable' }; | |||
const signout = { title: 'Disable & Sign Out' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
let result: MessageItem | undefined; | |||
if (this.container.integrationAuthentication.supports(this.authProvider.id)) { | |||
result = await window.showWarningMessage( | |||
`Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, | |||
{ modal: true }, | |||
disable, | |||
signout, | |||
cancel, | |||
); | |||
} else { | |||
result = await window.showWarningMessage( | |||
`Are you sure you want to disable the rich integration with ${this.name}?`, | |||
{ modal: true }, | |||
disable, | |||
cancel, | |||
); | |||
} | |||
if (result == null || result === cancel) return; | |||
if (result === signout) { | |||
void this.container.integrationAuthentication.deleteSession( | |||
this.authProvider.id, | |||
this.authProviderDescriptor, | |||
); | |||
} | |||
} | |||
} | |||
this.resetRequestExceptionCount(); | |||
this._session = null; | |||
if (connected) { | |||
// Don't store the disconnected flag if this only for this current VS Code session (will be re-connected on next restart) | |||
if (!options?.currentSessionOnly) { | |||
void this.container.storage.storeWorkspace(this.connectedKey, false); | |||
} | |||
this._onDidChange.fire(); | |||
if (!options?.silent && !options?.currentSessionOnly) { | |||
this.container.integrations.disconnected(this.key); | |||
} | |||
} | |||
} | |||
@log() | |||
async reauthenticate(): Promise<void> { | |||
if (this._session === undefined) return; | |||
this._session = undefined; | |||
void (await this.ensureSession(true, true)); | |||
} | |||
refresh() { | |||
void this.ensureSession(false); | |||
} | |||
private requestExceptionCount = 0; | |||
resetRequestExceptionCount(): void { | |||
this.requestExceptionCount = 0; | |||
} | |||
private handleProviderException<T>(ex: Error, scope: LogScope | undefined, defaultValue: T): T { | |||
if (ex instanceof CancellationError) return defaultValue; | |||
Logger.error(ex, scope); | |||
if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { | |||
this.trackRequestException(); | |||
} | |||
return defaultValue; | |||
} | |||
@debug() | |||
trackRequestException(): void { | |||
this.requestExceptionCount++; | |||
if (this.requestExceptionCount >= 5 && this._session !== null) { | |||
void this.disconnect({ currentSessionOnly: true }); | |||
} | |||
} | |||
@gate() | |||
@debug({ exit: true }) | |||
async isConnected(): Promise<boolean> { | |||
return (await this.session()) != null; | |||
} | |||
@gate() | |||
@debug() | |||
async getAccountForCommit( | |||
repo: T, | |||
ref: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
try { | |||
const author = await this.getProviderAccountForCommit(this._session!, repo, ref, options); | |||
this.resetRequestExceptionCount(); | |||
return author; | |||
} catch (ex) { | |||
return this.handleProviderException<Account | undefined>(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract getProviderAccountForCommit( | |||
session: AuthenticationSession, | |||
repo: T, | |||
ref: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined>; | |||
@gate() | |||
@debug() | |||
async getAccountForEmail( | |||
repo: T, | |||
email: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
try { | |||
const author = await this.getProviderAccountForEmail(this._session!, repo, email, options); | |||
this.resetRequestExceptionCount(); | |||
return author; | |||
} catch (ex) { | |||
return this.handleProviderException<Account | undefined>(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract getProviderAccountForEmail( | |||
session: AuthenticationSession, | |||
repo: T, | |||
email: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined>; | |||
@debug() | |||
async getDefaultBranch(repo: T): Promise<DefaultBranch | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const defaultBranch = this.container.cache.getRepositoryDefaultBranch(repo, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderDefaultBranch(this._session!, repo); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<DefaultBranch | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return defaultBranch; | |||
} | |||
protected abstract getProviderDefaultBranch( | |||
{ accessToken }: AuthenticationSession, | |||
repo: T, | |||
): Promise<DefaultBranch | undefined>; | |||
@debug() | |||
async getRepositoryMetadata(repo: T, _cancellation?: CancellationToken): Promise<RepositoryMetadata | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const metadata = this.container.cache.getRepositoryMetadata(repo, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderRepositoryMetadata(this._session!, repo); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<RepositoryMetadata | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return metadata; | |||
} | |||
protected abstract getProviderRepositoryMetadata( | |||
session: AuthenticationSession, | |||
repo: T, | |||
): Promise<RepositoryMetadata | undefined>; | |||
@debug() | |||
async getIssueOrPullRequest(repo: T, id: string): Promise<IssueOrPullRequest | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const issueOrPR = this.container.cache.getIssueOrPullRequest(id, repo, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderIssueOrPullRequest(this._session!, repo, id); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<IssueOrPullRequest | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return issueOrPR; | |||
} | |||
protected abstract getProviderIssueOrPullRequest( | |||
session: AuthenticationSession, | |||
repo: T, | |||
id: string, | |||
): Promise<IssueOrPullRequest | undefined>; | |||
@debug() | |||
async getPullRequestForBranch( | |||
repo: T, | |||
branch: string, | |||
options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const pr = this.container.cache.getPullRequestForBranch(branch, repo, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderPullRequestForBranch(this._session!, repo, branch, options); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<PullRequest | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return pr; | |||
} | |||
protected abstract getProviderPullRequestForBranch( | |||
session: AuthenticationSession, | |||
repo: T, | |||
branch: string, | |||
options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined>; | |||
@debug() | |||
async getPullRequestForCommit(repo: T, ref: string): Promise<PullRequest | undefined> { | |||
const scope = getLogScope(); | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
const pr = this.container.cache.getPullRequestForSha(ref, repo, this, () => ({ | |||
value: (async () => { | |||
try { | |||
const result = await this.getProviderPullRequestForCommit(this._session!, repo, ref); | |||
this.resetRequestExceptionCount(); | |||
return result; | |||
} catch (ex) { | |||
return this.handleProviderException<PullRequest | undefined>(ex, scope, undefined); | |||
} | |||
})(), | |||
})); | |||
return pr; | |||
} | |||
protected abstract getProviderPullRequestForCommit( | |||
session: AuthenticationSession, | |||
repo: T, | |||
ref: string, | |||
): Promise<PullRequest | undefined>; | |||
async getMyIssuesForRepos( | |||
reposOrRepoIds: ProviderReposInput, | |||
options?: { | |||
filters?: IssueFilter[]; | |||
cursor?: string; | |||
customUrl?: string; | |||
}, | |||
): Promise<PagedResult<ProviderIssue> | undefined> { | |||
const providerId = this.authProvider.id; | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
if ( | |||
providerId !== ProviderId.GitLab && | |||
(this.api.isRepoIdsInput(reposOrRepoIds) || | |||
(providerId === ProviderId.AzureDevOps && | |||
!reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) | |||
) { | |||
Logger.warn(`Unsupported input for provider ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
let getIssuesOptions: GetIssuesOptions | undefined; | |||
if (providerId === ProviderId.AzureDevOps) { | |||
const organizations = new Set<string>(); | |||
const projects = new Set<string>(); | |||
for (const repo of reposOrRepoIds as ProviderRepoInput[]) { | |||
organizations.add(repo.namespace); | |||
projects.add(repo.project!); | |||
} | |||
if (organizations.size > 1) { | |||
Logger.warn(`Multiple organizations not supported for provider ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} else if (organizations.size === 0) { | |||
Logger.warn(`No organizations found for provider ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
const organization: string = organizations.values().next().value; | |||
if (options?.filters != null) { | |||
if (!this.api.providerSupportsIssueFilters(providerId, options.filters)) { | |||
Logger.warn(`Unsupported filters for provider ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
let userAccount: ProviderAccount | undefined; | |||
try { | |||
userAccount = await this.api.getCurrentUserForInstance(providerId, organization); | |||
} catch (ex) { | |||
Logger.error(ex, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
if (userAccount == null) { | |||
Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
const userFilterProperty = userAccount.name; | |||
if (userFilterProperty == null) { | |||
Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
getIssuesOptions = { | |||
authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, | |||
assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, | |||
mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, | |||
}; | |||
} | |||
const cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
const cursors: PagedProjectInput[] = cursorInfo.cursors ?? []; | |||
let projectInputs: PagedProjectInput[] = Array.from(projects.values()).map(project => ({ | |||
namespace: organization, | |||
project: project, | |||
cursor: undefined, | |||
})); | |||
if (cursors.length > 0) { | |||
projectInputs = cursors; | |||
} | |||
try { | |||
const cursor: { cursors: PagedProjectInput[] } = { cursors: [] }; | |||
let hasMore = false; | |||
const data: ProviderIssue[] = []; | |||
await Promise.all( | |||
projectInputs.map(async projectInput => { | |||
const results = await this.api.getIssuesForAzureProject( | |||
projectInput.namespace, | |||
projectInput.project, | |||
{ | |||
...getIssuesOptions, | |||
cursor: projectInput.cursor, | |||
}, | |||
); | |||
data.push(...results.values); | |||
if (results.paging?.more) { | |||
hasMore = true; | |||
cursor.cursors.push({ | |||
namespace: projectInput.namespace, | |||
project: projectInput.project, | |||
cursor: results.paging.cursor, | |||
}); | |||
} | |||
}), | |||
); | |||
return { | |||
values: data, | |||
paging: { | |||
more: hasMore, | |||
cursor: JSON.stringify(cursor), | |||
}, | |||
}; | |||
} catch (ex) { | |||
Logger.error(ex, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
} | |||
if (options?.filters != null) { | |||
let userAccount: ProviderAccount | undefined; | |||
try { | |||
userAccount = await this.api.getCurrentUser(providerId); | |||
} catch (ex) { | |||
Logger.error(ex, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
if (userAccount == null) { | |||
Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
const userFilterProperty = userAccount.username; | |||
if (userFilterProperty == null) { | |||
Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
getIssuesOptions = { | |||
authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, | |||
assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, | |||
mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, | |||
}; | |||
} | |||
if ( | |||
this.api.getProviderIssuesPagingMode(providerId) === PagingMode.Repo && | |||
!this.api.isRepoIdsInput(reposOrRepoIds) | |||
) { | |||
const cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; | |||
let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); | |||
if (cursors.length > 0) { | |||
repoInputs = cursors; | |||
} | |||
try { | |||
const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; | |||
let hasMore = false; | |||
const data: ProviderIssue[] = []; | |||
await Promise.all( | |||
repoInputs.map(async repoInput => { | |||
const results = await this.api.getIssuesForRepo(providerId, repoInput.repo, { | |||
...getIssuesOptions, | |||
cursor: repoInput.cursor, | |||
baseUrl: options?.customUrl, | |||
}); | |||
data.push(...results.values); | |||
if (results.paging?.more) { | |||
hasMore = true; | |||
cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); | |||
} | |||
}), | |||
); | |||
return { | |||
values: data, | |||
paging: { | |||
more: hasMore, | |||
cursor: JSON.stringify(cursor), | |||
}, | |||
}; | |||
} catch (ex) { | |||
Logger.error(ex, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
} | |||
try { | |||
return await this.api.getIssuesForRepos(providerId, reposOrRepoIds, { | |||
...getIssuesOptions, | |||
cursor: options?.cursor, | |||
baseUrl: options?.customUrl, | |||
}); | |||
} catch (ex) { | |||
Logger.error(ex, 'getIssuesForRepos'); | |||
return undefined; | |||
} | |||
} | |||
async getMyPullRequestsForRepos( | |||
reposOrRepoIds: ProviderReposInput, | |||
options?: { | |||
filters?: PullRequestFilter[]; | |||
cursor?: string; | |||
customUrl?: string; | |||
}, | |||
): Promise<PagedResult<ProviderPullRequest> | undefined> { | |||
const providerId = this.authProvider.id; | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
if ( | |||
providerId !== ProviderId.GitLab && | |||
(this.api.isRepoIdsInput(reposOrRepoIds) || | |||
(providerId === ProviderId.AzureDevOps && | |||
!reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) | |||
) { | |||
Logger.warn(`Unsupported input for provider ${providerId}`); | |||
return undefined; | |||
} | |||
let getPullRequestsOptions: GetPullRequestsOptions | undefined; | |||
if (options?.filters != null) { | |||
if (!this.api.providerSupportsPullRequestFilters(providerId, options.filters)) { | |||
Logger.warn(`Unsupported filters for provider ${providerId}`, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
let userAccount: ProviderAccount | undefined; | |||
if (providerId === ProviderId.AzureDevOps) { | |||
const organizations = new Set<string>(); | |||
for (const repo of reposOrRepoIds as ProviderRepoInput[]) { | |||
organizations.add(repo.namespace); | |||
} | |||
if (organizations.size > 1) { | |||
Logger.warn( | |||
`Multiple organizations not supported for provider ${providerId}`, | |||
'getPullRequestsForRepos', | |||
); | |||
return undefined; | |||
} else if (organizations.size === 0) { | |||
Logger.warn(`No organizations found for provider ${providerId}`, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
const organization: string = organizations.values().next().value; | |||
try { | |||
userAccount = await this.api.getCurrentUserForInstance(providerId, organization); | |||
} catch (ex) { | |||
Logger.error(ex, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
} else { | |||
try { | |||
userAccount = await this.api.getCurrentUser(providerId); | |||
} catch (ex) { | |||
Logger.error(ex, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
} | |||
if (userAccount == null) { | |||
Logger.warn(`Unable to get current user for ${providerId}`, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
let userFilterProperty: string | null; | |||
switch (providerId) { | |||
case ProviderId.Bitbucket: | |||
case ProviderId.AzureDevOps: | |||
userFilterProperty = userAccount.id; | |||
break; | |||
default: | |||
userFilterProperty = userAccount.username; | |||
break; | |||
} | |||
if (userFilterProperty == null) { | |||
Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
getPullRequestsOptions = { | |||
authorLogin: options.filters.includes(PullRequestFilter.Author) ? userFilterProperty : undefined, | |||
assigneeLogins: options.filters.includes(PullRequestFilter.Assignee) ? [userFilterProperty] : undefined, | |||
reviewRequestedLogin: options.filters.includes(PullRequestFilter.ReviewRequested) | |||
? userFilterProperty | |||
: undefined, | |||
mentionLogin: options.filters.includes(PullRequestFilter.Mention) ? userFilterProperty : undefined, | |||
}; | |||
} | |||
if ( | |||
this.api.getProviderPullRequestsPagingMode(providerId) === PagingMode.Repo && | |||
!this.api.isRepoIdsInput(reposOrRepoIds) | |||
) { | |||
const cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; | |||
let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); | |||
if (cursors.length > 0) { | |||
repoInputs = cursors; | |||
} | |||
try { | |||
const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; | |||
let hasMore = false; | |||
const data: ProviderPullRequest[] = []; | |||
await Promise.all( | |||
repoInputs.map(async repoInput => { | |||
const results = await this.api.getPullRequestsForRepo(providerId, repoInput.repo, { | |||
...getPullRequestsOptions, | |||
cursor: repoInput.cursor, | |||
baseUrl: options?.customUrl, | |||
}); | |||
data.push(...results.values); | |||
if (results.paging?.more) { | |||
hasMore = true; | |||
cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); | |||
} | |||
}), | |||
); | |||
return { | |||
values: data, | |||
paging: { | |||
more: hasMore, | |||
cursor: JSON.stringify(cursor), | |||
}, | |||
}; | |||
} catch (ex) { | |||
Logger.error(ex, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
} | |||
try { | |||
return this.api.getPullRequestsForRepos(providerId, reposOrRepoIds, { | |||
...getPullRequestsOptions, | |||
cursor: options?.cursor, | |||
baseUrl: options?.customUrl, | |||
}); | |||
} catch (ex) { | |||
Logger.error(ex, 'getPullRequestsForRepos'); | |||
return undefined; | |||
} | |||
} | |||
@debug() | |||
async searchMyIssues(): Promise<SearchedIssue[] | undefined> { | |||
const scope = getLogScope(); | |||
try { | |||
const issues = await this.searchProviderMyIssues(this._session!); | |||
this.resetRequestExceptionCount(); | |||
return issues; | |||
} catch (ex) { | |||
return this.handleProviderException<SearchedIssue[] | undefined>(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract searchProviderMyIssues(session: AuthenticationSession): Promise<SearchedIssue[] | undefined>; | |||
@debug() | |||
async searchMyPullRequests(): Promise<SearchedPullRequest[] | undefined> { | |||
const scope = getLogScope(); | |||
try { | |||
const pullRequests = await this.searchProviderMyPullRequests(this._session!); | |||
this.resetRequestExceptionCount(); | |||
return pullRequests; | |||
} catch (ex) { | |||
return this.handleProviderException<SearchedPullRequest[] | undefined>(ex, scope, undefined); | |||
} | |||
} | |||
protected abstract searchProviderMyPullRequests( | |||
session: AuthenticationSession, | |||
): Promise<SearchedPullRequest[] | undefined>; | |||
@gate() | |||
private async ensureSession( | |||
createIfNeeded: boolean, | |||
forceNewSession: boolean = false, | |||
): Promise<AuthenticationSession | undefined> { | |||
if (this._session != null) return this._session; | |||
if (!configuration.get('integrations.enabled')) return undefined; | |||
if (createIfNeeded) { | |||
await this.container.storage.deleteWorkspace(this.connectedKey); | |||
} else if (this.container.storage.getWorkspace(this.connectedKey) === false) { | |||
return undefined; | |||
} | |||
let session: AuthenticationSession | undefined | null; | |||
try { | |||
session = await this.container.integrationAuthentication.getSession( | |||
this.authProvider.id, | |||
this.authProviderDescriptor, | |||
{ createIfNeeded: createIfNeeded, forceNewSession: forceNewSession }, | |||
); | |||
} catch (ex) { | |||
await this.container.storage.deleteWorkspace(this.connectedKey); | |||
if (ex instanceof Error && ex.message.includes('User did not consent')) { | |||
return undefined; | |||
} | |||
session = null; | |||
} | |||
if (session === undefined && !createIfNeeded) { | |||
await this.container.storage.deleteWorkspace(this.connectedKey); | |||
} | |||
this._session = session ?? null; | |||
this.resetRequestExceptionCount(); | |||
if (session != null) { | |||
await this.container.storage.storeWorkspace(this.connectedKey, true); | |||
queueMicrotask(() => { | |||
this._onDidChange.fire(); | |||
this.container.integrations.connected(this.key); | |||
}); | |||
} | |||
return session ?? undefined; | |||
} | |||
getIgnoreSSLErrors(): boolean | 'force' { | |||
return this.container.integrations.ignoreSSLErrors(this); | |||
} | |||
} | |||
export async function ensurePaidPlan(providerName: string, container: Container): Promise<boolean> { | |||
const title = `Connecting to a ${providerName} instance for rich integration features requires a trial or paid plan.`; | |||
while (true) { | |||
const subscription = await container.subscription.getSubscription(); | |||
if (subscription.account?.verified === false) { | |||
const resend = { title: 'Resend Verification' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nYou must verify your email before you can continue.`, | |||
{ modal: true }, | |||
resend, | |||
cancel, | |||
); | |||
if (result === resend) { | |||
if (await container.subscription.resendVerification()) { | |||
continue; | |||
} | |||
} | |||
return false; | |||
} | |||
const plan = subscription.plan.effective.id; | |||
if (isSubscriptionPaidPlan(plan)) break; | |||
if (subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { | |||
const startTrial = { title: 'Preview Pro' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nDo you want to preview ✨ features for 3 days?`, | |||
{ modal: true }, | |||
startTrial, | |||
cancel, | |||
); | |||
if (result !== startTrial) return false; | |||
void container.subscription.startPreviewTrial(); | |||
break; | |||
} else if (subscription.account == null) { | |||
const signIn = { title: 'Start Free GitKraken Trial' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nDo you want to continue to use ✨ features on privately hosted repos, free for an additional 7 days?`, | |||
{ modal: true }, | |||
signIn, | |||
cancel, | |||
); | |||
if (result === signIn) { | |||
if (await container.subscription.loginOrSignUp()) { | |||
continue; | |||
} | |||
} | |||
} else { | |||
const upgrade = { title: 'Upgrade to Pro' }; | |||
const cancel = { title: 'Cancel', isCloseAffordance: true }; | |||
const result = await window.showWarningMessage( | |||
`${title}\n\nDo you want to continue to use ✨ features on privately hosted repos?`, | |||
{ modal: true }, | |||
upgrade, | |||
cancel, | |||
); | |||
if (result === upgrade) { | |||
void container.subscription.purchase(); | |||
} | |||
} | |||
return false; | |||
} | |||
return true; | |||
} |
@ -0,0 +1,129 @@ | |||
import type { AuthenticationSession } from 'vscode'; | |||
import type { PagedResult } from '../../../git/gitProvider'; | |||
import type { Account } from '../../../git/models/author'; | |||
import type { DefaultBranch } from '../../../git/models/defaultBranch'; | |||
import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; | |||
import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; | |||
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; | |||
import { Logger } from '../../../system/logger'; | |||
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; | |||
import type { RepositoryDescriptor, SupportedProviderIds } from '../providerIntegration'; | |||
import { ProviderIntegration } from '../providerIntegration'; | |||
import type { ProviderRepository } from './models'; | |||
import { ProviderId, providersMetadata } from './models'; | |||
const metadata = providersMetadata[ProviderId.AzureDevOps]; | |||
const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); | |||
interface AzureRepositoryDescriptor extends RepositoryDescriptor { | |||
owner: string; | |||
name: string; | |||
} | |||
export class AzureDevOpsIntegration extends ProviderIntegration<AzureRepositoryDescriptor> { | |||
readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; | |||
readonly id: SupportedProviderIds = ProviderId.AzureDevOps; | |||
readonly name: string = 'Azure DevOps'; | |||
get domain(): string { | |||
return metadata.domain; | |||
} | |||
protected get apiBaseUrl(): string { | |||
return 'https://dev.azure.com'; | |||
} | |||
async getReposForAzureProject( | |||
namespace: string, | |||
project: string, | |||
options?: { cursor?: string }, | |||
): Promise<PagedResult<ProviderRepository> | undefined> { | |||
const connected = this.maybeConnected ?? (await this.isConnected()); | |||
if (!connected) return undefined; | |||
try { | |||
return await this.api.getReposForAzureProject(namespace, project, { cursor: options?.cursor }); | |||
} catch (ex) { | |||
Logger.error(ex, 'getReposForAzureProject'); | |||
return undefined; | |||
} | |||
} | |||
// TODO: implement | |||
protected override async getProviderAccountForCommit( | |||
_session: AuthenticationSession, | |||
_repo: AzureRepositoryDescriptor, | |||
_ref: string, | |||
_options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderAccountForEmail( | |||
_session: AuthenticationSession, | |||
_repo: AzureRepositoryDescriptor, | |||
_email: string, | |||
_options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderDefaultBranch( | |||
_session: AuthenticationSession, | |||
_repo: AzureRepositoryDescriptor, | |||
): Promise<DefaultBranch | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderIssueOrPullRequest( | |||
_session: AuthenticationSession, | |||
_repo: AzureRepositoryDescriptor, | |||
_id: string, | |||
): Promise<IssueOrPullRequest | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderPullRequestForBranch( | |||
_session: AuthenticationSession, | |||
_repo: AzureRepositoryDescriptor, | |||
_branch: string, | |||
_options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderPullRequestForCommit( | |||
_session: AuthenticationSession, | |||
_repo: AzureRepositoryDescriptor, | |||
_ref: string, | |||
): Promise<PullRequest | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderRepositoryMetadata( | |||
_session: AuthenticationSession, | |||
_repo: AzureRepositoryDescriptor, | |||
): Promise<RepositoryMetadata | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async searchProviderMyPullRequests( | |||
_session: AuthenticationSession, | |||
_repo?: AzureRepositoryDescriptor, | |||
): Promise<SearchedPullRequest[] | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async searchProviderMyIssues( | |||
_session: AuthenticationSession, | |||
_repo?: AzureRepositoryDescriptor, | |||
): Promise<SearchedIssue[] | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
} |
@ -0,0 +1,110 @@ | |||
import type { AuthenticationSession } from 'vscode'; | |||
import type { Account } from '../../../git/models/author'; | |||
import type { DefaultBranch } from '../../../git/models/defaultBranch'; | |||
import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; | |||
import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; | |||
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; | |||
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; | |||
import type { RepositoryDescriptor, SupportedProviderIds } from '../providerIntegration'; | |||
import { ProviderIntegration } from '../providerIntegration'; | |||
import { ProviderId, providersMetadata } from './models'; | |||
const metadata = providersMetadata[ProviderId.Bitbucket]; | |||
const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); | |||
interface BitbucketRepositoryDescriptor extends RepositoryDescriptor { | |||
owner: string; | |||
name: string; | |||
} | |||
export class BitbucketIntegration extends ProviderIntegration<BitbucketRepositoryDescriptor> { | |||
readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; | |||
readonly id: SupportedProviderIds = ProviderId.Bitbucket; | |||
readonly name: string = 'Bitbucket'; | |||
get domain(): string { | |||
return metadata.domain; | |||
} | |||
protected get apiBaseUrl(): string { | |||
return 'https://api.bitbucket.org/2.0'; | |||
} | |||
// TODO: implement | |||
protected override async getProviderAccountForCommit( | |||
_session: AuthenticationSession, | |||
_repo: BitbucketRepositoryDescriptor, | |||
_ref: string, | |||
_options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderAccountForEmail( | |||
_session: AuthenticationSession, | |||
_repo: BitbucketRepositoryDescriptor, | |||
_email: string, | |||
_options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderDefaultBranch( | |||
_session: AuthenticationSession, | |||
_repo: BitbucketRepositoryDescriptor, | |||
): Promise<DefaultBranch | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderIssueOrPullRequest( | |||
_session: AuthenticationSession, | |||
_repo: BitbucketRepositoryDescriptor, | |||
_id: string, | |||
): Promise<IssueOrPullRequest | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderPullRequestForBranch( | |||
_session: AuthenticationSession, | |||
_repo: BitbucketRepositoryDescriptor, | |||
_branch: string, | |||
_options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderPullRequestForCommit( | |||
_session: AuthenticationSession, | |||
_repo: BitbucketRepositoryDescriptor, | |||
_ref: string, | |||
): Promise<PullRequest | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async getProviderRepositoryMetadata( | |||
_session: AuthenticationSession, | |||
_repo: BitbucketRepositoryDescriptor, | |||
): Promise<RepositoryMetadata | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async searchProviderMyPullRequests( | |||
_session: AuthenticationSession, | |||
_repo?: BitbucketRepositoryDescriptor, | |||
): Promise<SearchedPullRequest[] | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override async searchProviderMyIssues( | |||
_session: AuthenticationSession, | |||
_repo?: BitbucketRepositoryDescriptor, | |||
): Promise<SearchedIssue[] | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
} |
@ -0,0 +1,196 @@ | |||
import type { AuthenticationSession } from 'vscode'; | |||
import type { Container } from '../../../container'; | |||
import type { Account } from '../../../git/models/author'; | |||
import type { DefaultBranch } from '../../../git/models/defaultBranch'; | |||
import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; | |||
import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; | |||
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; | |||
import { log } from '../../../system/decorators/log'; | |||
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; | |||
import type { SupportedProviderIds } from '../providerIntegration'; | |||
import { ensurePaidPlan, ProviderIntegration } from '../providerIntegration'; | |||
import { ProviderId, providersMetadata } from './models'; | |||
import type { ProvidersApi } from './providersApi'; | |||
const metadata = providersMetadata[ProviderId.GitHub]; | |||
const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ | |||
id: metadata.id, | |||
scopes: metadata.scopes, | |||
}); | |||
const enterpriseMetadata = providersMetadata[ProviderId.GitHubEnterprise]; | |||
const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ | |||
id: enterpriseMetadata.id, | |||
scopes: enterpriseMetadata.scopes, | |||
}); | |||
export type GitHubRepositoryDescriptor = { | |||
key: string; | |||
owner: string; | |||
name: string; | |||
}; | |||
export class GitHubIntegration extends ProviderIntegration<GitHubRepositoryDescriptor> { | |||
readonly authProvider = authProvider; | |||
readonly id: SupportedProviderIds = ProviderId.GitHub; | |||
readonly name: string = 'GitHub'; | |||
get domain(): string { | |||
return metadata.domain; | |||
} | |||
protected get apiBaseUrl(): string { | |||
return 'https://api.github.com'; | |||
} | |||
protected override async getProviderAccountForCommit( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitHubRepositoryDescriptor, | |||
ref: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return (await this.container.github)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { | |||
...options, | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderAccountForEmail( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitHubRepositoryDescriptor, | |||
email: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return (await this.container.github)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { | |||
...options, | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderDefaultBranch( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitHubRepositoryDescriptor, | |||
): Promise<DefaultBranch | undefined> { | |||
return (await this.container.github)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderIssueOrPullRequest( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitHubRepositoryDescriptor, | |||
id: string, | |||
): Promise<IssueOrPullRequest | undefined> { | |||
return (await this.container.github)?.getIssueOrPullRequest( | |||
this, | |||
accessToken, | |||
repo.owner, | |||
repo.name, | |||
Number(id), | |||
{ | |||
baseUrl: this.apiBaseUrl, | |||
}, | |||
); | |||
} | |||
protected override async getProviderPullRequestForBranch( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitHubRepositoryDescriptor, | |||
branch: string, | |||
options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined> { | |||
const { include, ...opts } = options ?? {}; | |||
const toGitHubPullRequestState = (await import(/* webpackChunkName: "github" */ './github/models')) | |||
.toGitHubPullRequestState; | |||
return (await this.container.github)?.getPullRequestForBranch( | |||
this, | |||
accessToken, | |||
repo.owner, | |||
repo.name, | |||
branch, | |||
{ | |||
...opts, | |||
include: include?.map(s => toGitHubPullRequestState(s)), | |||
baseUrl: this.apiBaseUrl, | |||
}, | |||
); | |||
} | |||
protected override async getProviderPullRequestForCommit( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitHubRepositoryDescriptor, | |||
ref: string, | |||
): Promise<PullRequest | undefined> { | |||
return (await this.container.github)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderRepositoryMetadata( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitHubRepositoryDescriptor, | |||
): Promise<RepositoryMetadata | undefined> { | |||
return (await this.container.github)?.getRepositoryMetadata(this, accessToken, repo.owner, repo.name, { | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async searchProviderMyPullRequests( | |||
{ accessToken }: AuthenticationSession, | |||
repo?: GitHubRepositoryDescriptor, | |||
): Promise<SearchedPullRequest[] | undefined> { | |||
return (await this.container.github)?.searchMyPullRequests(this, accessToken, { | |||
repos: repo != null ? [`${repo.owner}/${repo.name}`] : undefined, | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async searchProviderMyIssues( | |||
{ accessToken }: AuthenticationSession, | |||
repo?: GitHubRepositoryDescriptor, | |||
): Promise<SearchedIssue[] | undefined> { | |||
return (await this.container.github)?.searchMyIssues(this, accessToken, { | |||
repos: repo != null ? [`${repo.owner}/${repo.name}`] : undefined, | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
} | |||
export class GitHubEnterpriseIntegration extends GitHubIntegration { | |||
override readonly authProvider = enterpriseAuthProvider; | |||
override readonly id = ProviderId.GitHubEnterprise; | |||
override readonly name = 'GitHub Enterprise'; | |||
override get domain(): string { | |||
return this._domain; | |||
} | |||
protected override get apiBaseUrl(): string { | |||
return `https://${this._domain}/api/v3`; | |||
} | |||
protected override get key(): `${SupportedProviderIds}:${string}` { | |||
return `${this.id}:${this.domain}`; | |||
} | |||
constructor( | |||
container: Container, | |||
override readonly api: ProvidersApi, | |||
private readonly _domain: string, | |||
) { | |||
super(container, api); | |||
} | |||
@log() | |||
override async connect(): Promise<boolean> { | |||
if (!(await ensurePaidPlan(`${this.name} instance`, this.container))) { | |||
return false; | |||
} | |||
return super.connect(); | |||
} | |||
} |
@ -0,0 +1,190 @@ | |||
import type { AuthenticationSession } from 'vscode'; | |||
import type { Container } from '../../../container'; | |||
import type { Account } from '../../../git/models/author'; | |||
import type { DefaultBranch } from '../../../git/models/defaultBranch'; | |||
import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; | |||
import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; | |||
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; | |||
import { log } from '../../../system/decorators/log'; | |||
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; | |||
import type { SupportedProviderIds } from '../providerIntegration'; | |||
import { ensurePaidPlan, ProviderIntegration } from '../providerIntegration'; | |||
import { ProviderId, providersMetadata } from './models'; | |||
import type { ProvidersApi } from './providersApi'; | |||
const metadata = providersMetadata[ProviderId.GitLab]; | |||
const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ | |||
id: metadata.id, | |||
scopes: metadata.scopes, | |||
}); | |||
const enterpriseMetadata = providersMetadata[ProviderId.GitLabSelfHosted]; | |||
const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ | |||
id: enterpriseMetadata.id, | |||
scopes: enterpriseMetadata.scopes, | |||
}); | |||
export type GitLabRepositoryDescriptor = { | |||
key: string; | |||
owner: string; | |||
name: string; | |||
}; | |||
export class GitLabIntegration extends ProviderIntegration<GitLabRepositoryDescriptor> { | |||
readonly authProvider = authProvider; | |||
readonly id: SupportedProviderIds = ProviderId.GitLab; | |||
readonly name: string = 'GitLab'; | |||
get domain(): string { | |||
return metadata.domain; | |||
} | |||
protected get apiBaseUrl(): string { | |||
return 'https://gitlab.com/api'; | |||
} | |||
protected override async getProviderAccountForCommit( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitLabRepositoryDescriptor, | |||
ref: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return (await this.container.gitlab)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { | |||
...options, | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderAccountForEmail( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitLabRepositoryDescriptor, | |||
email: string, | |||
options?: { | |||
avatarSize?: number; | |||
}, | |||
): Promise<Account | undefined> { | |||
return (await this.container.gitlab)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { | |||
...options, | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderDefaultBranch( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitLabRepositoryDescriptor, | |||
): Promise<DefaultBranch | undefined> { | |||
return (await this.container.gitlab)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderIssueOrPullRequest( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitLabRepositoryDescriptor, | |||
id: string, | |||
): Promise<IssueOrPullRequest | undefined> { | |||
return (await this.container.gitlab)?.getIssueOrPullRequest( | |||
this, | |||
accessToken, | |||
repo.owner, | |||
repo.name, | |||
Number(id), | |||
{ | |||
baseUrl: this.apiBaseUrl, | |||
}, | |||
); | |||
} | |||
protected override async getProviderPullRequestForBranch( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitLabRepositoryDescriptor, | |||
branch: string, | |||
options?: { | |||
avatarSize?: number; | |||
include?: PullRequestState[]; | |||
}, | |||
): Promise<PullRequest | undefined> { | |||
const { include, ...opts } = options ?? {}; | |||
const toGitLabMergeRequestState = (await import(/* webpackChunkName: "gitlab" */ './gitlab/models')) | |||
.toGitLabMergeRequestState; | |||
return (await this.container.gitlab)?.getPullRequestForBranch( | |||
this, | |||
accessToken, | |||
repo.owner, | |||
repo.name, | |||
branch, | |||
{ | |||
...opts, | |||
include: include?.map(s => toGitLabMergeRequestState(s)), | |||
baseUrl: this.apiBaseUrl, | |||
}, | |||
); | |||
} | |||
protected override async getProviderPullRequestForCommit( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitLabRepositoryDescriptor, | |||
ref: string, | |||
): Promise<PullRequest | undefined> { | |||
return (await this.container.gitlab)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override async getProviderRepositoryMetadata( | |||
{ accessToken }: AuthenticationSession, | |||
repo: GitLabRepositoryDescriptor, | |||
): Promise<RepositoryMetadata | undefined> { | |||
return (await this.container.gitlab)?.getRepositoryMetadata(this, accessToken, repo.owner, repo.name, { | |||
baseUrl: this.apiBaseUrl, | |||
}); | |||
} | |||
protected override searchProviderMyPullRequests( | |||
_session: AuthenticationSession, | |||
_repo?: GitLabRepositoryDescriptor, | |||
): Promise<SearchedPullRequest[] | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
protected override searchProviderMyIssues( | |||
_session: AuthenticationSession, | |||
_repo?: GitLabRepositoryDescriptor, | |||
): Promise<SearchedIssue[] | undefined> { | |||
return Promise.resolve(undefined); | |||
} | |||
} | |||
export class GitLabSelfHostedIntegration extends GitLabIntegration { | |||
override readonly authProvider = enterpriseAuthProvider; | |||
override readonly id = ProviderId.GitHubEnterprise; | |||
override readonly name = 'GitLab Self-Hosted'; | |||
override get domain(): string { | |||
return this._domain; | |||
} | |||
protected override get apiBaseUrl(): string { | |||
return `https://${this._domain}/api`; | |||
} | |||
protected override get key(): `${SupportedProviderIds}:${string}` { | |||
return `${this.id}:${this.domain}`; | |||
} | |||
constructor( | |||
container: Container, | |||
override readonly api: ProvidersApi, | |||
private readonly _domain: string, | |||
) { | |||
super(container, api); | |||
} | |||
@log() | |||
override async connect(): Promise<boolean> { | |||
if (!(await ensurePaidPlan(`${this.name} instance`, this.container))) { | |||
return false; | |||
} | |||
return super.connect(); | |||
} | |||
} |
@ -0,0 +1,283 @@ | |||
import type { | |||
Account, | |||
AzureDevOps, | |||
Bitbucket, | |||
EnterpriseOptions, | |||
GetRepoInput, | |||
GitHub, | |||
GitLab, | |||
GitPullRequest, | |||
GitRepository, | |||
Issue, | |||
Jira, | |||
Trello, | |||
} from '@gitkraken/provider-apis'; | |||
export type ProviderAccount = Account; | |||
export type ProviderReposInput = (string | number)[] | GetRepoInput[]; | |||
export type ProviderRepoInput = GetRepoInput; | |||
export type ProviderPullRequest = GitPullRequest; | |||
export type ProviderRepository = GitRepository; | |||
export type ProviderIssue = Issue; | |||
export enum ProviderId { | |||
GitHub = 'github', | |||
GitHubEnterprise = 'github-enterprise', | |||
GitLab = 'gitlab', | |||
GitLabSelfHosted = 'gitlab-self-hosted', | |||
Bitbucket = 'bitbucket', | |||
Jira = 'jira', | |||
Trello = 'trello', | |||
AzureDevOps = 'azureDevOps', | |||
} | |||
export enum PullRequestFilter { | |||
Author = 'author', | |||
Assignee = 'assignee', | |||
ReviewRequested = 'review-requested', | |||
Mention = 'mention', | |||
} | |||
export enum IssueFilter { | |||
Author = 'author', | |||
Assignee = 'assignee', | |||
Mention = 'mention', | |||
} | |||
export enum PagingMode { | |||
Project = 'project', | |||
Repo = 'repo', | |||
Repos = 'repos', | |||
} | |||
export interface PagingInput { | |||
cursor?: string | null; | |||
page?: number; | |||
} | |||
export interface PagedRepoInput { | |||
repo: GetRepoInput; | |||
cursor?: string; | |||
} | |||
export interface PagedProjectInput { | |||
namespace: string; | |||
project: string; | |||
cursor?: string; | |||
} | |||
export interface GetPullRequestsOptions { | |||
authorLogin?: string; | |||
assigneeLogins?: string[]; | |||
reviewRequestedLogin?: string; | |||
mentionLogin?: string; | |||
cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} | |||
baseUrl?: string; | |||
} | |||
export interface GetPullRequestsForRepoInput extends GetPullRequestsOptions { | |||
repo: GetRepoInput; | |||
} | |||
export interface GetPullRequestsForReposInput extends GetPullRequestsOptions { | |||
repos: GetRepoInput[]; | |||
} | |||
export interface GetPullRequestsForRepoIdsInput extends GetPullRequestsOptions { | |||
repoIds: (string | number)[]; | |||
} | |||
export interface GetIssuesOptions { | |||
authorLogin?: string; | |||
assigneeLogins?: string[]; | |||
mentionLogin?: string; | |||
cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} | |||
baseUrl?: string; | |||
} | |||
export interface GetIssuesForRepoInput extends GetIssuesOptions { | |||
repo: GetRepoInput; | |||
} | |||
export interface GetIssuesForReposInput extends GetIssuesOptions { | |||
repos: GetRepoInput[]; | |||
} | |||
export interface GetIssuesForRepoIdsInput extends GetIssuesOptions { | |||
repoIds: (string | number)[]; | |||
} | |||
export interface GetIssuesForAzureProjectInput extends GetIssuesOptions { | |||
namespace: string; | |||
project: string; | |||
} | |||
export interface GetReposOptions { | |||
cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} | |||
} | |||
export interface GetReposForAzureProjectInput { | |||
namespace: string; | |||
project: string; | |||
} | |||
export interface PageInfo { | |||
hasNextPage: boolean; | |||
endCursor?: string | null; | |||
nextPage?: number | null; | |||
} | |||
export type GetPullRequestsForReposFn = ( | |||
input: (GetPullRequestsForReposInput | GetPullRequestsForRepoIdsInput) & PagingInput, | |||
options?: EnterpriseOptions, | |||
) => Promise<{ data: GitPullRequest[]; pageInfo?: PageInfo }>; | |||
export type GetPullRequestsForRepoFn = ( | |||
input: GetPullRequestsForRepoInput & PagingInput, | |||
options?: EnterpriseOptions, | |||
) => Promise<{ data: GitPullRequest[]; pageInfo?: PageInfo }>; | |||
export type GetIssuesForReposFn = ( | |||
input: (GetIssuesForReposInput | GetIssuesForRepoIdsInput) & PagingInput, | |||
options?: EnterpriseOptions, | |||
) => Promise<{ data: Issue[]; pageInfo?: PageInfo }>; | |||
export type GetIssuesForRepoFn = ( | |||
input: GetIssuesForRepoInput & PagingInput, | |||
options?: EnterpriseOptions, | |||
) => Promise<{ data: Issue[]; pageInfo?: PageInfo }>; | |||
export type GetIssuesForAzureProjectFn = ( | |||
input: GetIssuesForAzureProjectInput & PagingInput, | |||
options?: EnterpriseOptions, | |||
) => Promise<{ data: Issue[]; pageInfo?: PageInfo }>; | |||
export type GetReposForAzureProjectFn = ( | |||
input: GetReposForAzureProjectInput & PagingInput, | |||
options?: EnterpriseOptions, | |||
) => Promise<{ data: GitRepository[]; pageInfo?: PageInfo }>; | |||
export type getCurrentUserFn = (options?: EnterpriseOptions) => Promise<{ data: Account }>; | |||
export type getCurrentUserForInstanceFn = ( | |||
input: { namespace: string }, | |||
options?: EnterpriseOptions, | |||
) => Promise<{ data: Account }>; | |||
export interface ProviderInfo extends ProviderMetadata { | |||
provider: GitHub | GitLab | Bitbucket | Jira | Trello | AzureDevOps; | |||
getPullRequestsForReposFn?: GetPullRequestsForReposFn; | |||
getPullRequestsForRepoFn?: GetPullRequestsForRepoFn; | |||
getIssuesForReposFn?: GetIssuesForReposFn; | |||
getIssuesForRepoFn?: GetIssuesForRepoFn; | |||
getIssuesForAzureProjectFn?: GetIssuesForAzureProjectFn; | |||
getCurrentUserFn?: getCurrentUserFn; | |||
getCurrentUserForInstanceFn?: getCurrentUserForInstanceFn; | |||
getReposForAzureProjectFn?: GetReposForAzureProjectFn; | |||
} | |||
export interface ProviderMetadata { | |||
domain: string; | |||
id: ProviderId; | |||
issuesPagingMode?: PagingMode; | |||
pullRequestsPagingMode?: PagingMode; | |||
scopes: string[]; | |||
supportedPullRequestFilters?: PullRequestFilter[]; | |||
supportedIssueFilters?: IssueFilter[]; | |||
} | |||
export type Providers = Record<ProviderId, ProviderInfo>; | |||
export type ProvidersMetadata = Record<ProviderId, ProviderMetadata>; | |||
export const providersMetadata: ProvidersMetadata = { | |||
[ProviderId.GitHub]: { | |||
domain: 'github.com', | |||
id: ProviderId.GitHub, | |||
issuesPagingMode: PagingMode.Repos, | |||
pullRequestsPagingMode: PagingMode.Repos, | |||
// Use 'username' property on account for PR filters | |||
supportedPullRequestFilters: [ | |||
PullRequestFilter.Author, | |||
PullRequestFilter.Assignee, | |||
PullRequestFilter.ReviewRequested, | |||
PullRequestFilter.Mention, | |||
], | |||
// Use 'username' property on account for issue filters | |||
supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], | |||
scopes: ['repo', 'read:user', 'user:email'], | |||
}, | |||
[ProviderId.GitHubEnterprise]: { | |||
domain: '', | |||
id: ProviderId.GitHubEnterprise, | |||
issuesPagingMode: PagingMode.Repos, | |||
pullRequestsPagingMode: PagingMode.Repos, | |||
// Use 'username' property on account for PR filters | |||
supportedPullRequestFilters: [ | |||
PullRequestFilter.Author, | |||
PullRequestFilter.Assignee, | |||
PullRequestFilter.ReviewRequested, | |||
PullRequestFilter.Mention, | |||
], | |||
// Use 'username' property on account for issue filters | |||
supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], | |||
scopes: ['repo', 'read:user', 'user:email'], | |||
}, | |||
[ProviderId.GitLab]: { | |||
domain: 'gitlab.com', | |||
id: ProviderId.GitLab, | |||
issuesPagingMode: PagingMode.Repo, | |||
pullRequestsPagingMode: PagingMode.Repo, | |||
// Use 'username' property on account for PR filters | |||
supportedPullRequestFilters: [ | |||
PullRequestFilter.Author, | |||
PullRequestFilter.Assignee, | |||
PullRequestFilter.ReviewRequested, | |||
], | |||
// Use 'username' property on account for issue filters | |||
supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], | |||
scopes: ['read_api', 'read_user', 'read_repository'], | |||
}, | |||
[ProviderId.GitLabSelfHosted]: { | |||
domain: '', | |||
id: ProviderId.GitLabSelfHosted, | |||
issuesPagingMode: PagingMode.Repo, | |||
pullRequestsPagingMode: PagingMode.Repo, | |||
// Use 'username' property on account for PR filters | |||
supportedPullRequestFilters: [ | |||
PullRequestFilter.Author, | |||
PullRequestFilter.Assignee, | |||
PullRequestFilter.ReviewRequested, | |||
], | |||
// Use 'username' property on account for issue filters | |||
supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], | |||
scopes: ['read_api', 'read_user', 'read_repository'], | |||
}, | |||
[ProviderId.Bitbucket]: { | |||
domain: 'bitbucket.org', | |||
id: ProviderId.Bitbucket, | |||
pullRequestsPagingMode: PagingMode.Repo, | |||
// Use 'id' property on account for PR filters | |||
supportedPullRequestFilters: [PullRequestFilter.Author], | |||
scopes: ['account:read', 'repository:read', 'pullrequest:read', 'issue:read'], | |||
}, | |||
[ProviderId.AzureDevOps]: { | |||
domain: 'dev.azure.com', | |||
id: ProviderId.AzureDevOps, | |||
issuesPagingMode: PagingMode.Project, | |||
pullRequestsPagingMode: PagingMode.Repo, | |||
// Use 'id' property on account for PR filters | |||
supportedPullRequestFilters: [PullRequestFilter.Author, PullRequestFilter.Assignee], | |||
// Use 'name' property on account for issue filters | |||
supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], | |||
scopes: ['vso.code', 'vso.identity', 'vso.project', 'vso.profile', 'vso.work'], | |||
}, | |||
[ProviderId.Jira]: { | |||
domain: 'atlassian.net', | |||
id: ProviderId.Jira, | |||
scopes: [], | |||
}, | |||
[ProviderId.Trello]: { | |||
domain: 'trello.com', | |||
id: ProviderId.Trello, | |||
scopes: [], | |||
}, | |||
}; |
@ -0,0 +1,609 @@ | |||
import ProviderApis from '@gitkraken/provider-apis'; | |||
import type { Container } from '../../../container'; | |||
import type { PagedResult } from '../../../git/gitProvider'; | |||
import type { | |||
getCurrentUserFn, | |||
getCurrentUserForInstanceFn, | |||
GetIssuesForAzureProjectFn, | |||
GetIssuesForRepoFn, | |||
GetIssuesForReposFn, | |||
GetIssuesOptions, | |||
GetPullRequestsForRepoFn, | |||
GetPullRequestsForReposFn, | |||
GetPullRequestsOptions, | |||
GetReposForAzureProjectFn, | |||
GetReposOptions, | |||
IssueFilter, | |||
PagingMode, | |||
ProviderAccount, | |||
ProviderInfo, | |||
ProviderIssue, | |||
ProviderPullRequest, | |||
ProviderRepoInput, | |||
ProviderReposInput, | |||
ProviderRepository, | |||
Providers, | |||
PullRequestFilter, | |||
} from './models'; | |||
import { ProviderId, providersMetadata } from './models'; | |||
export class ProvidersApi { | |||
private readonly providers: Providers; | |||
constructor(private readonly container: Container) { | |||
const providerApis = ProviderApis(); | |||
this.providers = { | |||
[ProviderId.GitHub]: { | |||
...providersMetadata[ProviderId.GitHub], | |||
provider: providerApis.github, | |||
getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as getCurrentUserFn, | |||
getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( | |||
providerApis.github, | |||
) as GetPullRequestsForReposFn, | |||
getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( | |||
providerApis.github, | |||
) as GetIssuesForReposFn, | |||
}, | |||
[ProviderId.GitHubEnterprise]: { | |||
...providersMetadata[ProviderId.GitHubEnterprise], | |||
provider: providerApis.github, | |||
getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as getCurrentUserFn, | |||
getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( | |||
providerApis.github, | |||
) as GetPullRequestsForReposFn, | |||
getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( | |||
providerApis.github, | |||
) as GetIssuesForReposFn, | |||
}, | |||
[ProviderId.GitLab]: { | |||
...providersMetadata[ProviderId.GitLab], | |||
provider: providerApis.gitlab, | |||
getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as getCurrentUserFn, | |||
getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( | |||
providerApis.gitlab, | |||
) as GetPullRequestsForReposFn, | |||
getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( | |||
providerApis.gitlab, | |||
) as GetPullRequestsForRepoFn, | |||
getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( | |||
providerApis.gitlab, | |||
) as GetIssuesForReposFn, | |||
getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( | |||
providerApis.gitlab, | |||
) as GetIssuesForRepoFn, | |||
}, | |||
[ProviderId.GitLabSelfHosted]: { | |||
...providersMetadata[ProviderId.GitLabSelfHosted], | |||
provider: providerApis.gitlab, | |||
getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as getCurrentUserFn, | |||
getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( | |||
providerApis.gitlab, | |||
) as GetPullRequestsForReposFn, | |||
getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( | |||
providerApis.gitlab, | |||
) as GetPullRequestsForRepoFn, | |||
getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( | |||
providerApis.gitlab, | |||
) as GetIssuesForReposFn, | |||
getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( | |||
providerApis.gitlab, | |||
) as GetIssuesForRepoFn, | |||
}, | |||
[ProviderId.Bitbucket]: { | |||
...providersMetadata[ProviderId.Bitbucket], | |||
provider: providerApis.bitbucket, | |||
getCurrentUserFn: providerApis.bitbucket.getCurrentUser.bind( | |||
providerApis.bitbucket, | |||
) as getCurrentUserFn, | |||
getPullRequestsForReposFn: providerApis.bitbucket.getPullRequestsForRepos.bind( | |||
providerApis.bitbucket, | |||
) as GetPullRequestsForReposFn, | |||
getPullRequestsForRepoFn: providerApis.bitbucket.getPullRequestsForRepo.bind( | |||
providerApis.bitbucket, | |||
) as GetPullRequestsForRepoFn, | |||
}, | |||
[ProviderId.AzureDevOps]: { | |||
...providersMetadata[ProviderId.AzureDevOps], | |||
provider: providerApis.azureDevOps, | |||
getCurrentUserForInstanceFn: providerApis.azureDevOps.getCurrentUserForInstance.bind( | |||
providerApis.azureDevOps, | |||
) as getCurrentUserForInstanceFn, | |||
getPullRequestsForReposFn: providerApis.azureDevOps.getPullRequestsForRepos.bind( | |||
providerApis.azureDevOps, | |||
) as GetPullRequestsForReposFn, | |||
getPullRequestsForRepoFn: providerApis.azureDevOps.getPullRequestsForRepo.bind( | |||
providerApis.azureDevOps, | |||
) as GetPullRequestsForRepoFn, | |||
getIssuesForAzureProjectFn: providerApis.azureDevOps.getIssuesForAzureProject.bind( | |||
providerApis.azureDevOps, | |||
) as GetIssuesForAzureProjectFn, | |||
getReposForAzureProjectFn: providerApis.azureDevOps.getReposForAzureProject.bind( | |||
providerApis.azureDevOps, | |||
) as GetReposForAzureProjectFn, | |||
}, | |||
[ProviderId.Jira]: { | |||
...providersMetadata[ProviderId.Jira], | |||
provider: providerApis.jira, | |||
}, | |||
[ProviderId.Trello]: { | |||
...providersMetadata[ProviderId.Trello], | |||
provider: providerApis.trello, | |||
}, | |||
}; | |||
} | |||
getScopesForProvider(providerId: ProviderId): string[] | undefined { | |||
return this.providers[providerId]?.scopes; | |||
} | |||
getProviderDomain(providerId: ProviderId): string | undefined { | |||
return this.providers[providerId]?.domain; | |||
} | |||
getProviderPullRequestsPagingMode(providerId: ProviderId): PagingMode | undefined { | |||
return this.providers[providerId]?.pullRequestsPagingMode; | |||
} | |||
getProviderIssuesPagingMode(providerId: ProviderId): PagingMode | undefined { | |||
return this.providers[providerId]?.issuesPagingMode; | |||
} | |||
providerSupportsPullRequestFilters(providerId: ProviderId, filters: PullRequestFilter[]): boolean { | |||
return ( | |||
this.providers[providerId]?.supportedPullRequestFilters != null && | |||
filters.every(filter => this.providers[providerId]?.supportedPullRequestFilters?.includes(filter)) | |||
); | |||
} | |||
providerSupportsIssueFilters(providerId: ProviderId, filters: IssueFilter[]): boolean { | |||
return ( | |||
this.providers[providerId]?.supportedIssueFilters != null && | |||
filters.every(filter => this.providers[providerId]?.supportedIssueFilters?.includes(filter)) | |||
); | |||
} | |||
isRepoIdsInput(input: any): input is (string | number)[] { | |||
return ( | |||
input != null && | |||
Array.isArray(input) && | |||
input.every((id: any) => typeof id === 'string' || typeof id === 'number') | |||
); | |||
} | |||
async getProviderToken( | |||
provider: ProviderInfo, | |||
options?: { createSessionIfNeeded?: boolean }, | |||
): Promise<string | undefined> { | |||
const providerDescriptor = | |||
provider.domain == null || provider.scopes == null | |||
? undefined | |||
: { domain: provider.domain, scopes: provider.scopes }; | |||
try { | |||
return ( | |||
await this.container.integrationAuthentication.getSession(provider.id, providerDescriptor, { | |||
createIfNeeded: options?.createSessionIfNeeded, | |||
}) | |||
)?.accessToken; | |||
} catch { | |||
return undefined; | |||
} | |||
} | |||
async getPullRequestsForRepos( | |||
providerId: ProviderId, | |||
reposOrIds: ProviderReposInput, | |||
options?: GetPullRequestsOptions, | |||
): Promise<PagedResult<ProviderPullRequest>> { | |||
const provider = this.providers[providerId]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${providerId} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${providerId}`); | |||
} | |||
if (provider.getPullRequestsForReposFn == null) { | |||
throw new Error(`Provider with id ${providerId} does not support getting pull requests for repositories`); | |||
} | |||
let cursorInfo; | |||
try { | |||
cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
} catch { | |||
cursorInfo = {}; | |||
} | |||
const cursorValue = cursorInfo.value; | |||
const cursorType = cursorInfo.type; | |||
let cursorOrPage = {}; | |||
if (cursorType === 'page') { | |||
cursorOrPage = { page: cursorValue }; | |||
} else if (cursorType === 'cursor') { | |||
cursorOrPage = { cursor: cursorValue }; | |||
} | |||
const input = { | |||
...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), | |||
...options, | |||
...cursorOrPage, | |||
}; | |||
const result = await provider.getPullRequestsForReposFn(input, { token: token, isPAT: true }); | |||
const hasMore = result.pageInfo?.hasNextPage ?? false; | |||
let nextCursor = '{}'; | |||
if (result.pageInfo?.endCursor != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); | |||
} else if (result.pageInfo?.nextPage != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); | |||
} | |||
return { | |||
values: result.data, | |||
paging: { | |||
cursor: nextCursor, | |||
more: hasMore, | |||
}, | |||
}; | |||
} | |||
async getPullRequestsForRepo( | |||
providerId: ProviderId, | |||
repo: ProviderRepoInput, | |||
options?: GetPullRequestsOptions, | |||
): Promise<PagedResult<ProviderPullRequest>> { | |||
const provider = this.providers[providerId]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${providerId} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${providerId}`); | |||
} | |||
if (provider.getPullRequestsForRepoFn == null) { | |||
throw new Error(`Provider with id ${providerId} does not support getting pull requests for a repository`); | |||
} | |||
let cursorInfo; | |||
try { | |||
cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
} catch { | |||
cursorInfo = {}; | |||
} | |||
const cursorValue = cursorInfo.value; | |||
const cursorType = cursorInfo.type; | |||
let cursorOrPage = {}; | |||
if (cursorType === 'page') { | |||
cursorOrPage = { page: cursorValue }; | |||
} else if (cursorType === 'cursor') { | |||
cursorOrPage = { cursor: cursorValue }; | |||
} | |||
const result = await provider.getPullRequestsForRepoFn( | |||
{ | |||
repo: repo, | |||
...options, | |||
...cursorOrPage, | |||
}, | |||
{ token: token, isPAT: true }, | |||
); | |||
const hasMore = result.pageInfo?.hasNextPage ?? false; | |||
let nextCursor = '{}'; | |||
if (result.pageInfo?.endCursor != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); | |||
} else if (result.pageInfo?.nextPage != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); | |||
} | |||
return { | |||
values: result.data, | |||
paging: { | |||
cursor: nextCursor, | |||
more: hasMore, | |||
}, | |||
}; | |||
} | |||
async getIssuesForRepos( | |||
providerId: ProviderId, | |||
reposOrIds: ProviderReposInput, | |||
options?: GetIssuesOptions, | |||
): Promise<PagedResult<ProviderIssue>> { | |||
const provider = this.providers[providerId]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${providerId} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${providerId}`); | |||
} | |||
if (provider.getIssuesForReposFn == null) { | |||
throw new Error(`Provider with id ${providerId} does not support getting issues for repositories`); | |||
} | |||
if (provider.id === ProviderId.AzureDevOps) { | |||
throw new Error( | |||
`Provider with id ${providerId} does not support getting issues for repositories; use getIssuesForAzureProject instead`, | |||
); | |||
} | |||
let cursorInfo; | |||
try { | |||
cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
} catch { | |||
cursorInfo = {}; | |||
} | |||
const cursorValue = cursorInfo.value; | |||
const cursorType = cursorInfo.type; | |||
let cursorOrPage = {}; | |||
if (cursorType === 'page') { | |||
cursorOrPage = { page: cursorValue }; | |||
} else if (cursorType === 'cursor') { | |||
cursorOrPage = { cursor: cursorValue }; | |||
} | |||
const input = { | |||
...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), | |||
...options, | |||
...cursorOrPage, | |||
}; | |||
const result = await provider.getIssuesForReposFn(input, { token: token, isPAT: true }); | |||
const hasMore = result.pageInfo?.hasNextPage ?? false; | |||
let nextCursor = '{}'; | |||
if (result.pageInfo?.endCursor != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); | |||
} else if (result.pageInfo?.nextPage != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); | |||
} | |||
return { | |||
values: result.data, | |||
paging: { | |||
cursor: nextCursor, | |||
more: hasMore, | |||
}, | |||
}; | |||
} | |||
async getIssuesForRepo( | |||
providerId: ProviderId, | |||
repo: ProviderRepoInput, | |||
options?: GetIssuesOptions, | |||
): Promise<PagedResult<ProviderIssue>> { | |||
const provider = this.providers[providerId]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${providerId} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${providerId}`); | |||
} | |||
if (provider.getIssuesForRepoFn == null) { | |||
throw new Error(`Provider with id ${providerId} does not support getting issues for a repository`); | |||
} | |||
if (provider.id === ProviderId.AzureDevOps) { | |||
throw new Error( | |||
`Provider with id ${providerId} does not support getting issues for a repository; use getIssuesForAzureProject instead`, | |||
); | |||
} | |||
let cursorInfo; | |||
try { | |||
cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
} catch { | |||
cursorInfo = {}; | |||
} | |||
const cursorValue = cursorInfo.value; | |||
const cursorType = cursorInfo.type; | |||
let cursorOrPage = {}; | |||
if (cursorType === 'page') { | |||
cursorOrPage = { page: cursorValue }; | |||
} else if (cursorType === 'cursor') { | |||
cursorOrPage = { cursor: cursorValue }; | |||
} | |||
const result = await provider.getIssuesForRepoFn( | |||
{ | |||
repo: repo, | |||
...options, | |||
...cursorOrPage, | |||
}, | |||
{ token: token, isPAT: true }, | |||
); | |||
const hasMore = result.pageInfo?.hasNextPage ?? false; | |||
let nextCursor = '{}'; | |||
if (result.pageInfo?.endCursor != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); | |||
} else if (result.pageInfo?.nextPage != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); | |||
} | |||
return { | |||
values: result.data, | |||
paging: { | |||
cursor: nextCursor, | |||
more: hasMore, | |||
}, | |||
}; | |||
} | |||
async getIssuesForAzureProject( | |||
namespace: string, | |||
project: string, | |||
options?: GetIssuesOptions, | |||
): Promise<PagedResult<ProviderIssue>> { | |||
const provider = this.providers[ProviderId.AzureDevOps]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${ProviderId.AzureDevOps} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${ProviderId.AzureDevOps}`); | |||
} | |||
if (provider.getIssuesForAzureProjectFn == null) { | |||
throw new Error( | |||
`Provider with id ${ProviderId.AzureDevOps} does not support getting issues for an Azure project`, | |||
); | |||
} | |||
let cursorInfo; | |||
try { | |||
cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
} catch { | |||
cursorInfo = {}; | |||
} | |||
const cursorValue = cursorInfo.value; | |||
const cursorType = cursorInfo.type; | |||
let cursorOrPage = {}; | |||
if (cursorType === 'page') { | |||
cursorOrPage = { page: cursorValue }; | |||
} else if (cursorType === 'cursor') { | |||
cursorOrPage = { cursor: cursorValue }; | |||
} | |||
const result = await provider.getIssuesForAzureProjectFn( | |||
{ | |||
namespace: namespace, | |||
project: project, | |||
...options, | |||
...cursorOrPage, | |||
}, | |||
{ token: token, isPAT: true }, | |||
); | |||
const hasMore = result.pageInfo?.hasNextPage ?? false; | |||
let nextCursor = '{}'; | |||
if (result.pageInfo?.endCursor != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); | |||
} else if (result.pageInfo?.nextPage != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); | |||
} | |||
return { | |||
values: result.data, | |||
paging: { | |||
cursor: nextCursor, | |||
more: hasMore, | |||
}, | |||
}; | |||
} | |||
async getReposForAzureProject( | |||
namespace: string, | |||
project: string, | |||
options?: GetReposOptions, | |||
): Promise<PagedResult<ProviderRepository>> { | |||
const provider = this.providers[ProviderId.AzureDevOps]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${ProviderId.AzureDevOps} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${ProviderId.AzureDevOps}`); | |||
} | |||
if (provider.getReposForAzureProjectFn == null) { | |||
throw new Error( | |||
`Provider with id ${ProviderId.AzureDevOps} does not support getting repositories for Azure projects`, | |||
); | |||
} | |||
let cursorInfo; | |||
try { | |||
cursorInfo = JSON.parse(options?.cursor ?? '{}'); | |||
} catch { | |||
cursorInfo = {}; | |||
} | |||
const cursorValue = cursorInfo.value; | |||
const cursorType = cursorInfo.type; | |||
let cursorOrPage = {}; | |||
if (cursorType === 'page') { | |||
cursorOrPage = { page: cursorValue }; | |||
} else if (cursorType === 'cursor') { | |||
cursorOrPage = { cursor: cursorValue }; | |||
} | |||
const result = await provider.getReposForAzureProjectFn( | |||
{ | |||
namespace: namespace, | |||
project: project, | |||
...cursorOrPage, | |||
}, | |||
{ token: token, isPAT: true }, | |||
); | |||
const hasMore = result.pageInfo?.hasNextPage ?? false; | |||
let nextCursor = '{}'; | |||
if (result.pageInfo?.endCursor != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); | |||
} else if (result.pageInfo?.nextPage != null) { | |||
nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); | |||
} | |||
return { | |||
values: result.data, | |||
paging: { | |||
cursor: nextCursor, | |||
more: hasMore, | |||
}, | |||
}; | |||
} | |||
async getCurrentUser(providerId: ProviderId): Promise<ProviderAccount> { | |||
const provider = this.providers[providerId]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${providerId} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${providerId}`); | |||
} | |||
if (provider.getCurrentUserFn == null) { | |||
throw new Error(`Provider with id ${providerId} does not support getting current user`); | |||
} | |||
const { data: account } = await provider.getCurrentUserFn({ token: token, isPAT: true }); | |||
return account; | |||
} | |||
async getCurrentUserForInstance(providerId: ProviderId, namespace: string): Promise<ProviderAccount> { | |||
const provider = this.providers[providerId]; | |||
if (provider == null) { | |||
throw new Error(`Provider with id ${providerId} not registered`); | |||
} | |||
const token = await this.getProviderToken(provider); | |||
if (token == null) { | |||
throw new Error(`Not connected to provider ${providerId}`); | |||
} | |||
if (provider.getCurrentUserForInstanceFn == null) { | |||
throw new Error(`Provider with id ${providerId} does not support getting current user for an instance`); | |||
} | |||
const { data: account } = await provider.getCurrentUserForInstanceFn( | |||
{ namespace: namespace }, | |||
{ token: token, isPAT: true }, | |||
); | |||
return account; | |||
} | |||
} |