diff --git a/src/container.ts b/src/container.ts index 0b73282ff8d5e..270c1656015bb 100644 --- a/src/container.ts +++ b/src/container.ts @@ -35,6 +35,7 @@ import type { CloudIntegrationService } from './plus/integrations/authentication import { ConfiguredIntegrationService } from './plus/integrations/authentication/configuredIntegrationService'; import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthenticationService'; import { IntegrationService } from './plus/integrations/integrationService'; +import type { AzureDevOpsApi } from './plus/integrations/providers/azure/azure'; import type { GitHubApi } from './plus/integrations/providers/github/github'; import type { GitLabApi } from './plus/integrations/providers/gitlab/gitlab'; import { EnrichmentService } from './plus/launchpad/enrichmentService'; @@ -477,6 +478,28 @@ export class Container { return this._git; } + private _azure: Promise | undefined; + get azure(): Promise { + if (this._azure == null) { + async function load(this: Container) { + try { + const azure = new ( + await import(/* webpackChunkName: "integrations" */ './plus/integrations/providers/azure/azure') + ).AzureDevOpsApi(this); + this._disposables.push(azure); + return azure; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._azure = load.call(this); + } + + return this._azure; + } + private _github: Promise | undefined; get github(): Promise { if (this._github == null) { diff --git a/src/plus/integrations/providers/azure/azure.ts b/src/plus/integrations/providers/azure/azure.ts new file mode 100644 index 0000000000000..aede6a736c897 --- /dev/null +++ b/src/plus/integrations/providers/azure/azure.ts @@ -0,0 +1,378 @@ +import type { AzurePullRequestEntityIdentifierInput } from '@gitkraken/provider-apis'; +import type { HttpsProxyAgent } from 'https-proxy-agent'; +import type { CancellationToken, Disposable, Event } from 'vscode'; +import { window } from 'vscode'; +import type { RequestInit, Response } from '@env/fetch'; +import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; +import { isWeb } from '@env/platform'; +import type { Container } from '../../../../container'; +import { + AuthenticationError, + AuthenticationErrorReason, + CancellationError, + ProviderFetchError, + RequestClientError, + RequestNotFoundError, + RequestRateLimitError, +} from '../../../../errors'; +import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import { + showIntegrationRequestFailed500WarningMessage, + showIntegrationRequestTimedOutWarningMessage, +} from '../../../../messages'; +import { configuration } from '../../../../system/-webview/configuration'; +import { debug } from '../../../../system/decorators/log'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; + +export class AzureDevOpsApi implements Disposable { + private readonly _disposable: Disposable; + + constructor(_container: Container) { + this._disposable = configuration.onDidChangeAny(e => { + if ( + configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) || + configuration.changed(e, ['outputLevel', 'proxy']) + ) { + this.resetCaches(); + } + }); + } + + dispose(): void { + this._disposable.dispose(); + } + + private _proxyAgent: HttpsProxyAgent | null | undefined = null; + private get proxyAgent(): HttpsProxyAgent | undefined { + if (isWeb) return undefined; + + if (this._proxyAgent === null) { + this._proxyAgent = getProxyAgent(); + } + return this._proxyAgent; + } + + private resetCaches(): void { + this._proxyAgent = null; + // this._defaults.clear(); + // this._enterpriseVersions.clear(); + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getIssueOrPullRequest( + provider: Provider, + token: string, + owner: string, + repo: string, + number: number, + options: { + baseUrl: string; + }, + ): Promise { + const scope = getLogScope(); + const [projectName, _, repoName] = repo.split('/'); + + try { + interface ResultAzureUser { + displayName: string; + url: string; + _links: { + avatar: { + href: string; + }; + }; + id: string; + uniqueName: string; + imageUrl: string; + descriptor: string; + } + interface WorkItemResult { + _links: { + fields: { + href: string; + }; + html: { + href: string; + }; + self: { + href: string; + }; + workItemComments: { + href: string; + }; + workItemRevisions: { + href: string; + }; + workItemType: { + href: string; + }; + workItemUpdates: { + href: string; + }; + }; + fields: { + 'System.AreaPath': string; + 'System.TeamProject': string; + 'System.IterationPath': string; + 'System.WorkItemType': string; + 'System.State': string; + 'System.Reason': string; + 'System.CreatedDate': string; + 'System.CreatedBy': ResultAzureUser; + 'System.ChangedDate': string; + 'System.ChangedBy': ResultAzureUser; + 'System.CommentCount': number; + 'System.Title': string; + 'Microsoft.VSTS.Common.StateChangeDate': string; + 'Microsoft.VSTS.Common.Priority': number; + 'Microsoft.VSTS.Common.Severity': string; + 'Microsoft.VSTS.Common.ValueArea': string; + }; + id: number; + rev: number; + url: string; + } + // Try to get the Work item (wit) first with specific fields + const witResult = await this.request( + provider, + token, + options?.baseUrl, + `${owner}/${projectName}/_apis/wit/workItems/${number}?fields=System.Title`, + { + method: 'GET', + }, + scope, + ); + + if (witResult != null) { + return { + id: witResult.id.toString(), + type: 'issue', + nodeId: witResult.id.toString(), + provider: provider, + createdDate: new Date(witResult.fields['System.CreatedDate']), + updatedDate: new Date(witResult.fields['System.ChangedDate']), + state: witResult.fields['System.State'] === 'Closed' ? 'closed' : 'opened', + closed: witResult.fields['System.State'] === 'Closed', + title: witResult.fields['System.Title'], + url: witResult._links.html.href, + }; + } + + interface PullRequestResult { + repository: unknown; + pullRequestId: number; + codeReviewId: number; + status: string; + createdBy: ResultAzureUser; + creationDate: string; + closedDate: string; + title: string; + description: string; + sourceRefName: string; + targetRefName: string; + isDraft: boolean; + mergeId: string; + lastMergeSourceCommit: { + commitId: string; + url: string; + }; + lastMergeTargetCommit: { + commitId: string; + url: string; + }; + reviewers: unknown[]; + url: string; + _links: { + self: { + href: string; + }; + repository: { + href: string; + }; + workItems: { + href: string; + }; + sourceBranch: { + href: string; + }; + targetBranch: { + href: string; + }; + statuses: { + href: string; + }; + sourceCommit: { + href: string; + }; + targetCommit: { + href: string; + }; + createdBy: { + href: string; + }; + iterations: { + href: string; + }; + }; + supportsIterations: boolean; + artifactId: string; + } + + const prResult = await this.request( + provider, + token, + options?.baseUrl, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests/${number}`, + { + method: 'GET', + }, + scope, + ); + + if (prResult != null) { + return { + id: prResult.pullRequestId.toString(), + type: 'pullrequest', + nodeId: prResult.pullRequestId.toString(), + provider: provider, + createdDate: new Date(prResult.creationDate), + updatedDate: new Date(prResult.creationDate), + state: prResult.status === 'completed' ? 'merged' : 'opened', + closed: prResult.status === 'completed', + title: prResult.title, + url: prResult.url, + }; + } + + return undefined; + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + private async request( + provider: Provider, + token: string, + baseUrl: string, + route: string, + options: { method: RequestInit['method'] } & Record, + scope: LogScope | undefined, + cancellation?: CancellationToken | undefined, + ): Promise { + const url = `${baseUrl}/${route}`; + + let rsp: Response; + try { + const sw = maybeStopWatch(`[AZURE] ${options?.method ?? 'GET'} ${url}`, { log: false }); + const agent = this.proxyAgent; + + try { + let aborter: AbortController | undefined; + if (cancellation != null) { + if (cancellation.isCancellationRequested) throw new CancellationError(); + + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter!.abort()); + } + + rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () => + fetch(url, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + agent: agent, + signal: aborter?.signal, + ...options, + }), + ); + + if (rsp.ok) { + const data: T = await rsp.json(); + return data; + } + + throw new ProviderFetchError('AzureDevOps', rsp); + } finally { + sw?.stop(); + } + } catch (ex) { + if (ex instanceof ProviderFetchError || ex.name === 'AbortError') { + this.handleRequestError(provider, token, ex, scope); + } else if (Logger.isDebugging) { + void window.showErrorMessage(`AzureDevOps request failed: ${ex.message}`); + } + + throw ex; + } + } + + private handleRequestError( + provider: Provider | undefined, + _token: string, + ex: ProviderFetchError | (Error & { name: 'AbortError' }), + scope: LogScope | undefined, + ): void { + if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex); + + switch (ex.status) { + case 404: // Not found + case 410: // Gone + case 422: // Unprocessable Entity + throw new RequestNotFoundError(ex); + case 401: // Unauthorized + throw new AuthenticationError('azureDevOps', AuthenticationErrorReason.Unauthorized, ex); + // TODO: Learn the Azure API docs and put it in order: + // case 403: // Forbidden + // if (ex.message.includes('rate limit')) { + // let resetAt: number | undefined; + + // const reset = ex.response?.headers?.get('x-ratelimit-reset'); + // if (reset != null) { + // resetAt = parseInt(reset, 10); + // if (Number.isNaN(resetAt)) { + // resetAt = undefined; + // } + // } + + // throw new RequestRateLimitError(ex, token, resetAt); + // } + // throw new AuthenticationError('azure', AuthenticationErrorReason.Forbidden, ex); + case 500: // Internal Server Error + Logger.error(ex, scope); + if (ex.response != null) { + provider?.trackRequestException(); + void showIntegrationRequestFailed500WarningMessage( + `${provider?.name ?? 'AzureDevOps'} failed to respond and might be experiencing issues.${ + provider == null || provider.id === 'azure' + ? ' Please visit the [AzureDevOps status page](https://status.dev.azure.com) for more information.' + : '' + }`, + ); + } + return; + case 502: // Bad Gateway + Logger.error(ex, scope); + // TODO: Learn the Azure API docs and put it in order: + // if (ex.message.includes('timeout')) { + // provider?.trackRequestException(); + // void showIntegrationRequestTimedOutWarningMessage(provider?.name ?? 'Azure'); + // return; + // } + break; + default: + if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex); + break; + } + + Logger.error(ex, scope); + if (Logger.isDebugging) { + void window.showErrorMessage( + `AzureDevOps request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`, + ); + } + } +} diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index a0413b1f000ce..842cba1264131 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -101,11 +101,20 @@ export class AzureDevOpsIntegration extends HostingIntegration< } protected override async getProviderIssueOrPullRequest( - _session: AuthenticationSession, - _repo: AzureRepositoryDescriptor, - _id: string, + { accessToken }: AuthenticationSession, + repo: AzureRepositoryDescriptor, + id: string, ): Promise { - return Promise.resolve(undefined); + return (await this.container.azure)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + Number(id), + { + baseUrl: this.apiBaseUrl, + }, + ); } protected override async getProviderIssue( diff --git a/src/plus/startWork/startWork.ts b/src/plus/startWork/startWork.ts index f414fa76c1bfb..34bb8314d0b3d 100644 --- a/src/plus/startWork/startWork.ts +++ b/src/plus/startWork/startWork.ts @@ -90,6 +90,7 @@ export interface StartWorkOverrides { } export const supportedStartWorkIntegrations = [ + HostingIntegrationId.AzureDevOps, HostingIntegrationId.GitHub, SelfHostedIntegrationId.CloudGitHubEnterprise, HostingIntegrationId.GitLab,