diff --git a/src/autolinks/autolinks.ts b/src/autolinks/autolinks.ts index e7c234db5b286..a539ae6576cb0 100644 --- a/src/autolinks/autolinks.ts +++ b/src/autolinks/autolinks.ts @@ -5,8 +5,11 @@ import type { IntegrationId } from '../constants.integrations'; import { IssueIntegrationId } from '../constants.integrations'; import type { Container } from '../container'; import type { GitRemote } from '../git/models/remote'; +import type { RemoteProviderId } from '../git/remotes/remoteProvider'; import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/utils/-webview/icons'; -import type { HostingIntegration, IssueIntegration } from '../plus/integrations/integration'; +import type { HostingIntegration, Integration, IssueIntegration } from '../plus/integrations/integration'; +import { IntegrationBase } from '../plus/integrations/integration'; +import { remoteProviderIdToIntegrationId } from '../plus/integrations/integrationService'; import { configuration } from '../system/-webview/configuration'; import { fromNow } from '../system/date'; import { debug } from '../system/decorators/log'; @@ -214,9 +217,29 @@ export class Autolinks implements Disposable { const enrichedAutolinks = new Map(); for (const [id, link] of messageOrAutolinks) { - let linkIntegration = link.provider - ? await this.container.integrations.get(link.provider.id as IntegrationId) - : undefined; + let integrationId: IntegrationId | undefined; + let linkIntegration: Integration | undefined; + if (link.provider != null) { + // Try to make a smart choice + integrationId = + link.provider instanceof IntegrationBase + ? link.provider.id + : // TODO: Tighten the typing on ProviderReference to be specific to a remote provider, and then have a separate "integration" property (on autolinks and elsewhere) + // that is of a new type IntegrationReference specific to integrations. Otherwise, make remote provider ids line up directly with integration ids. + // Either way, this converting/casting hackery needs to go away. + remoteProviderIdToIntegrationId(link.provider.id as RemoteProviderId); + if (integrationId == null) { + // Fall back to the old logic assuming that integration id might be saved as provider id. + // TODO: it should be removed when we put providers and integrations in order. Conversation: https://github.com/gitkraken/vscode-gitlens/pull/3996#discussion_r1936422826 + integrationId = link.provider.id as IntegrationId; + } + try { + linkIntegration = await this.container.integrations.get(integrationId); + } catch (e) { + Logger.error(e, `Failed to get integration for ${link.provider.id}`); + linkIntegration = undefined; + } + } if (linkIntegration != null) { const connected = linkIntegration.maybeConnected ?? (await linkIntegration.isConnected()); if (!connected || !(await linkIntegration.access())) { @@ -226,7 +249,7 @@ export class Autolinks implements Disposable { const issueOrPullRequestPromise = remote?.provider != null && integration != null && - link.provider?.id === integration.id && + integrationId === integration.id && link.provider?.domain === integration.domain ? integration.getIssueOrPullRequest( link.descriptor ?? remote.provider.repoDesc, 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/integrationService.ts b/src/plus/integrations/integrationService.ts index b92784a1d5cd6..7296621f94f15 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -1033,9 +1033,6 @@ export function remoteProviderIdToIntegrationId( remoteProviderId: RemoteProviderId, ): SupportedCloudIntegrationIds | undefined { switch (remoteProviderId) { - // TODO: Uncomment when we support these integrations - // case 'bitbucket': - // return HostingIntegrationId.Bitbucket; case 'azure-devops': return HostingIntegrationId.AzureDevOps; case 'github': diff --git a/src/plus/integrations/providers/azure/azure.ts b/src/plus/integrations/providers/azure/azure.ts new file mode 100644 index 0000000000000..cc2ff16d5d483 --- /dev/null +++ b/src/plus/integrations/providers/azure/azure.ts @@ -0,0 +1,427 @@ +import type { HttpsProxyAgent } from 'https-proxy-agent'; +import type { CancellationToken, Disposable } 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, +} from '../../../../errors'; +import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest'; +import type { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import { showIntegrationRequestFailed500WarningMessage } 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'; +import type { + AzurePullRequest, + AzurePullRequestWithLinks, + AzureWorkItemState, + AzureWorkItemStateCategory, + WorkItem, +} from './models'; +import { + azurePullRequestStatusToState, + azureWorkItemsStateCategoryToState, + fromAzurePullRequest, + getAzurePullRequestWebUrl, + isClosedAzurePullRequestStatus, + isClosedAzureWorkItemStateCategory, +} from './models'; + +export class AzureDevOpsApi implements Disposable { + private readonly _disposable: Disposable; + private _workItemStates: WorkItemStates = new WorkItemStates(); + + 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._workItemStates.clear(); + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getPullRequestForBranch( + provider: Provider, + token: string, + owner: string, + repo: string, + branch: string, + options: { + baseUrl: string; + }, + ): Promise { + const scope = getLogScope(); + const [projectName, _, repoName] = repo.split('/'); + + try { + const prResult = await this.request<{ value: AzurePullRequest[] }>( + provider, + token, + options?.baseUrl, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests`, + { + method: 'GET', + }, + scope, + ); + + const pr = prResult?.value.find(pr => pr.sourceRefName.endsWith(branch)); + if (pr == null) return undefined; + + return fromAzurePullRequest(pr, provider, owner, projectName); + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getIssueOrPullRequest( + provider: Provider, + token: string, + owner: string, + repo: string, + id: string, + options: { + baseUrl: string; + }, + ): Promise { + const scope = getLogScope(); + const [projectName, _, repoName] = repo.split('/'); + + try { + // Try to get the Work item (wit) first with specific fields + const issueResult = await this.request( + provider, + token, + options?.baseUrl, + `${owner}/${projectName}/_apis/wit/workItems/${id}`, + { + method: 'GET', + }, + scope, + ); + + if (issueResult != null) { + const issueType = issueResult.fields['System.WorkItemType']; + const state = issueResult.fields['System.State']; + const stateCategory = await this.getWorkItemStateCategory( + issueType, + state, + provider, + token, + owner, + repo, + options, + ); + + return { + id: issueResult.id.toString(), + type: 'issue', + nodeId: issueResult.id.toString(), + provider: provider, + createdDate: new Date(issueResult.fields['System.CreatedDate']), + updatedDate: new Date(issueResult.fields['System.ChangedDate']), + state: azureWorkItemsStateCategoryToState(stateCategory), + closed: isClosedAzureWorkItemStateCategory(stateCategory), + title: issueResult.fields['System.Title'], + url: issueResult._links.html.href, + }; + } + } catch (ex) { + if (ex.original?.status !== 404) { + Logger.error(ex, scope); + return undefined; + } + } + + try { + const prResult = await this.request( + provider, + token, + options?.baseUrl, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests/${id}`, + { + method: 'GET', + }, + scope, + ); + + if (prResult != null) { + return { + id: prResult.pullRequestId.toString(), + type: 'pullrequest', + nodeId: prResult.pullRequestId.toString(), // prResult.artifactId maybe? + provider: provider, + createdDate: new Date(prResult.creationDate), + updatedDate: new Date(prResult.creationDate), + state: azurePullRequestStatusToState(prResult.status), + closed: isClosedAzurePullRequestStatus(prResult.status), + title: prResult.title, + url: getAzurePullRequestWebUrl(prResult), + }; + } + + return undefined; + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + public async getWorkItemStateCategory( + issueType: string, + state: string, + provider: Provider, + token: string, + owner: string, + repo: string, + options: { + baseUrl: string; + }, + ): Promise { + const [projectName] = repo.split('/'); + const project = `${owner}/${projectName}`; + const category = this._workItemStates.getStateCategory(project, issueType, state); + if (category != null) return category; + + const states = await this.retrieveWorkItemTypeStates(issueType, provider, token, owner, repo, options); + this._workItemStates.saveTypeStates(project, issueType, states); + + return this._workItemStates.getStateCategory(project, issueType, state); + } + + private async retrieveWorkItemTypeStates( + workItemType: string, + provider: Provider, + token: string, + owner: string, + repo: string, + options: { + baseUrl: string; + }, + ): Promise { + const scope = getLogScope(); + const [projectName] = repo.split('/'); + + try { + const issueResult = await this.request<{ value: AzureWorkItemState[]; count: number }>( + provider, + token, + options?.baseUrl, + `${owner}/${projectName}/_apis/wit/workItemTypes/${workItemType}/states`, + { + method: 'GET', + }, + scope, + ); + + return issueResult?.value ?? []; + } catch (ex) { + Logger.error(ex, scope); + return []; + } + } + + 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}`, + ); + } + } +} + +class WorkItemStates { + private readonly _categories = new Map(); + private readonly _types = new Map(); + + // TODO@sergeibbb: we might need some logic for invalidating + public getStateCategory( + project: string, + workItemType: string, + stateName: string, + ): AzureWorkItemStateCategory | undefined { + return this._categories.get(this.getStateKey(project, workItemType, stateName)); + } + + public clear(): void { + this._categories.clear(); + this._types.clear(); + } + + public saveTypeStates(project: string, workItemType: string, states: AzureWorkItemState[]): void { + this.clearTypeStates(project, workItemType); + this._types.set(this.getTypeKey(project, workItemType), states); + for (const state of states) { + this._categories.set(this.getStateKey(project, workItemType, state.name), state.category); + } + } + + public hasTypeStates(project: string, workItemType: string): boolean { + return this._types.has(this.getTypeKey(project, workItemType)); + } + + private clearTypeStates(project: string, workItemType: string): void { + const states = this._types.get(this.getTypeKey(project, workItemType)); + if (states == null) return; + for (const state of states) { + this._categories.delete(this.getStateKey(project, workItemType, state.name)); + } + } + + private getStateKey(project: string, workItemType: string, stateName: string): string { + // By stringifying the pair as JSON we make sure that all possible special characters are escaped + return JSON.stringify([project, workItemType, stateName]); + } + + private getTypeKey(project: string, workItemType: string): string { + return JSON.stringify([project, workItemType]); + } +} diff --git a/src/plus/integrations/providers/azure/models.ts b/src/plus/integrations/providers/azure/models.ts new file mode 100644 index 0000000000000..c26fdb11f2c7d --- /dev/null +++ b/src/plus/integrations/providers/azure/models.ts @@ -0,0 +1,462 @@ +import { RepositoryAccessLevel } from '../../../../git/models/issue'; +import type { IssueOrPullRequestState } from '../../../../git/models/issueOrPullRequest'; +import type { PullRequestMember, PullRequestReviewer } from '../../../../git/models/pullRequest'; +import { + PullRequest, + PullRequestMergeableState, + PullRequestReviewDecision, + PullRequestReviewState, +} from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; + +const vstsHostnameRegex = /\.visualstudio\.com$/; + +export type AzureWorkItemStateCategory = 'Proposed' | 'InProgress' | 'Resolved' | 'Completed' | 'Removed'; + +export function isClosedAzureWorkItemStateCategory(category: AzureWorkItemStateCategory | undefined): boolean { + return category === 'Completed' || category === 'Resolved' || category === 'Removed'; +} + +export function azureWorkItemsStateCategoryToState( + category: AzureWorkItemStateCategory | undefined, +): IssueOrPullRequestState { + switch (category) { + case 'Resolved': + case 'Completed': + case 'Removed': + return 'closed'; + case 'Proposed': + case 'InProgress': + default: + return 'opened'; + } +} + +export interface AzureLink { + href: string; +} + +export interface AzureUser { + displayName: string; + url: string; + _links: { + avatar: AzureLink; + }; + id: string; + uniqueName: string; + imageUrl: string; + descriptor?: string; +} + +export interface AzureUserWithVote extends AzureUser { + isFlagged?: boolean; + hasDeclined?: boolean; + isReapprove?: boolean; + isRequired?: boolean; + vote?: AzurePullRequestVote; +} + +export type AzurePullRequestVote = + | 10 // approved + | 5 // approved with suggestions + | 0 // no vote + | -5 // waiting for author + | -10; // rejected + +export interface AzureWorkItemCommentVersionRef { + commentId: number; + createdInRevision: number; + isDeleted: boolean; + text: string; + url: string; + version: number; +} + +export interface AzureWorkItemRelation { + attributes: { + [key: string]: string; + }; + relation: string; + url: string; +} + +export interface WorkItem { + _links: { + fields: AzureLink; + html: AzureLink; + self: AzureLink; + workItemComments: AzureLink; + workItemRevisions: AzureLink; + workItemType: AzureLink; + workItemUpdates: AzureLink; + }; + fields: { + // 'System.AreaPath': string; + // 'System.TeamProject': string; + // 'System.IterationPath': string; + 'System.WorkItemType': string; + 'System.State': string; + // 'System.Reason': string; + 'System.CreatedDate': string; + // 'System.CreatedBy': AzureUser; + 'System.ChangedDate': string; + // 'System.ChangedBy': AzureUser; + // '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; + commentVersionRef?: AzureWorkItemCommentVersionRef; + relations?: AzureWorkItemRelation[]; +} + +export interface AzureWorkItemState { + name: string; + color: string; + category: AzureWorkItemStateCategory; +} + +export type AzurePullRequestStatus = 'abandoned' | 'active' | 'completed' | 'notSet'; +export function azurePullRequestStatusToState(status: AzurePullRequestStatus): IssueOrPullRequestState { + switch (status) { + case 'abandoned': + return 'closed'; + case 'completed': + return 'merged'; + case 'active': + case 'notSet': + default: + return 'opened'; + } +} +export function isClosedAzurePullRequestStatus(status: AzurePullRequestStatus): boolean { + return azurePullRequestStatusToState(status) !== 'opened'; +} + +export type AzureProjectState = 'createPending' | 'deleted' | 'deleting' | 'new' | 'unchanged' | 'wellFormed'; +export type AzureProjectVisibility = 'private' | 'public'; + +export interface AzureProject { + id: string; + name: string; + url: string; + state: AzureProjectState; + revision: number; + visibility: AzureProjectVisibility; + lastUpdateTime: string; +} + +export interface AzureRepository { + id: string; + name: string; + url: string; + project: AzureProject; + size: number; + remoteUrl: string; + sshUrl: string; + webUrl: string; + isDisabled: boolean; + isInMaintenance: boolean; +} + +export interface AzureGitCommitRef { + commitId: string; + url: string; +} + +export interface AzureResourceRef { + id: string; + url: string; +} + +export interface AzurePullRequestCompletionOptions { + autoCompleteIgnoreConflicts: number[]; + bypassPolicy: boolean; + bypassReason: string; + deleteSourceBranch: boolean; + mergeCommitMessage: string; + mergeStrategy: 'noFastForward' | 'rebase' | 'rebaseMerge' | 'squash'; + squashMerge: boolean; + transitionWorkItems: boolean; + triggeredByAutoComplete: boolean; +} + +export interface AzureGitStatus { + context: { + name: string; + genre: string; + }; + createdBy: AzureUser; + createDate: string; + description: string; + state: 'error' | 'failed' | 'notApplicable' | 'notSet' | 'pending' | 'succeeded'; + targetUrl: string; + updateDate: string; +} + +export interface AzureGitForkRef { + creator: AzureUser; + isLocked: boolean; + isLockedBy: AzureUser; + name: string; + objectId: string; + peeledObjectId: string; + repository: AzureRepository; + statuses: AzureGitStatus[]; + url: string; +} + +export interface AzureWebApiTagDefinition { + active: boolean; + id: string; + name: string; + url: string; +} + +export interface AzureGitPullRequestMergeOptions { + conflictAuthorshipCommits: boolean; + detectRenameFalsePositives: boolean; + disableRenames: boolean; +} + +export type AzurePullRequestAsyncStatus = + | 'conflicts' + | 'failure' + | 'notSet' + | 'queued' + | 'rejectedByPolicy' + | 'succeeded'; + +export interface AzurePullRequest { + repository: AzureRepository; + pullRequestId: number; + codeReviewId: number; + status: AzurePullRequestStatus; + createdBy: AzureUser; + creationDate: string; + closedDate?: string; + closedBy?: AzureUser; // Can be missed even if closedDate is presented. + title: string; + description: string; + forkSource?: AzureGitForkRef; + sourceRefName: string; + targetRefName: string; + isDraft: boolean; + mergeId: string; + mergeStatus?: AzurePullRequestAsyncStatus; + lastMergeCommit?: AzureGitCommitRef; + lastMergeSourceCommit: AzureGitCommitRef; + lastMergeTargetCommit: AzureGitCommitRef; + reviewers: AzureUserWithVote[]; + url: string; + supportsIterations: boolean; +} + +export interface AzurePullRequestWithLinks extends AzurePullRequest { + _links: { + self: AzureLink; + repository: AzureLink; + workItems: AzureLink; + sourceBranch: AzureLink; + targetBranch: AzureLink; + statuses: AzureLink; + sourceCommit: AzureLink; + targetCommit: AzureLink; + createdBy: AzureLink; + iterations: AzureLink; + }; + artifactId: string; + autoCompleteSetBy?: AzureUser; + commits?: AzureGitCommitRef[]; + completionOptions?: AzurePullRequestCompletionOptions; + completionQueueTime?: string; + hasMultipleMergeBases?: boolean; + labels?: AzureWebApiTagDefinition[]; + mergeFailureMessage?: string; + mergeFailureType?: 'caseSensitive' | 'none' | 'objectTooLarge' | 'unknown'; + mergeOptions?: AzureGitPullRequestMergeOptions; + remoteUrl?: string; + workItemRefs?: AzureResourceRef[]; +} + +export function getVSTSOwner(url: URL): string { + return url.hostname.split('.')[0]; +} +export function getAzureDevOpsOwner(url: URL): string { + return url.pathname.split('/')[1]; +} +export function getAzureOwner(url: URL): string { + const isVSTS = vstsHostnameRegex.test(url.hostname); + return isVSTS ? getVSTSOwner(url) : getAzureDevOpsOwner(url); +} + +export function getAzureRepo(pr: AzurePullRequest): string { + return `${pr.repository.project.name}/_git/${pr.repository.name}`; +} + +export function getAzurePullRequestWebUrl(pr: AzurePullRequest): string { + const url = new URL(pr.url); + const baseUrl = new URL(url.origin).toString(); + const repoPath = getAzureRepo(pr); + const isVSTS = vstsHostnameRegex.test(url.hostname); + if (isVSTS) { + return `${baseUrl}/${repoPath}/pullrequest/${pr.pullRequestId}`; + } + const owner = getAzureDevOpsOwner(url); + return `${baseUrl}/${owner}/${repoPath}/pullrequest/${pr.pullRequestId}`; +} + +export function fromAzurePullRequestMergeStatusToMergeableState( + mergeStatus: AzurePullRequestAsyncStatus, +): PullRequestMergeableState { + switch (mergeStatus) { + case 'conflicts': + return PullRequestMergeableState.Conflicting; + case 'failure': + return PullRequestMergeableState.FailingChecks; + case 'rejectedByPolicy': + return PullRequestMergeableState.BlockedByPolicy; + case 'succeeded': + return PullRequestMergeableState.Mergeable; + case 'notSet': + case 'queued': + default: + return PullRequestMergeableState.Unknown; + } +} + +export function fromAzurePullRequestVoteToReviewState(vote: AzurePullRequestVote): PullRequestReviewState { + switch (vote) { + case 10: + case 5: + return PullRequestReviewState.Approved; + case 0: + return PullRequestReviewState.ReviewRequested; + case -5: + case -10: + return PullRequestReviewState.ChangesRequested; + default: + return PullRequestReviewState.ReviewRequested; + } +} + +export function fromAzureUserWithVoteToReviewer(reviewer: AzureUserWithVote): PullRequestReviewer { + return { + isCodeOwner: undefined, + reviewer: { + avatarUrl: reviewer.imageUrl, + id: reviewer.id, + name: reviewer.displayName, + url: reviewer.url, + }, + state: fromAzurePullRequestVoteToReviewState(reviewer.vote ?? 0), + }; +} + +export function getAzurePullRequestReviewDecision( + votes: AzurePullRequestVote[], +): PullRequestReviewDecision | undefined { + const reviewStates = votes.map(vote => fromAzurePullRequestVoteToReviewState(vote)); + if (reviewStates.includes(PullRequestReviewState.ChangesRequested)) { + return PullRequestReviewDecision.ChangesRequested; + } + + if (reviewStates.includes(PullRequestReviewState.ReviewRequested)) { + return PullRequestReviewDecision.ReviewRequired; + } + + if (reviewStates.includes(PullRequestReviewState.Approved)) { + return PullRequestReviewDecision.Approved; + } + + return undefined; +} + +export function fromAzureReviewerToPullRequestMember(reviewer: AzureUser): PullRequestMember { + return { + avatarUrl: reviewer.imageUrl, + id: reviewer.id, + name: reviewer.displayName, + url: reviewer.url, + }; +} + +function normalizeAzureBranchName(branchName: string): string { + return branchName.startsWith('refs/heads/') ? branchName.replace('refs/heads/', '') : branchName; +} + +export function fromAzurePullRequest( + pr: AzurePullRequest, + provider: Provider, + orgName: string, + projectName: string, +): PullRequest { + const url = new URL(pr.url); + return new PullRequest( + provider, + { + id: pr.createdBy.id, + name: pr.createdBy.displayName, + avatarUrl: pr.createdBy.imageUrl, + url: pr.createdBy.url, + }, + pr.pullRequestId.toString(), + pr.pullRequestId.toString(), + pr.title, + getAzurePullRequestWebUrl(pr), + { + owner: getAzureOwner(url), + repo: pr.repository.name, + id: pr.repository.id, + // TODO: Remove this assumption once actual access level is available + accessLevel: RepositoryAccessLevel.Write, + }, + azurePullRequestStatusToState(pr.status), + new Date(pr.creationDate), + new Date(pr.closedDate || pr.creationDate), + pr.closedDate ? new Date(pr.closedDate) : undefined, + pr.closedDate && pr.status === 'completed' ? new Date(pr.closedDate) : undefined, + fromAzurePullRequestMergeStatusToMergeableState(pr.mergeStatus ?? 'notSet'), + undefined, + { + base: { + branch: pr.targetRefName ? normalizeAzureBranchName(pr.targetRefName) : '', + sha: pr.lastMergeTargetCommit?.commitId ?? '', + repo: pr.repository.name, + owner: getAzureOwner(url), + exists: pr.targetRefName != null, + url: pr.repository.webUrl, + }, + head: { + branch: pr.sourceRefName ? normalizeAzureBranchName(pr.sourceRefName) : '', + sha: pr.lastMergeSourceCommit?.commitId ?? '', + repo: pr.forkSource?.repository != null ? pr.forkSource.repository.name : pr.repository.name, + owner: getAzureOwner(url), + exists: pr.sourceRefName != null, + url: pr.forkSource?.repository != null ? pr.forkSource.repository.webUrl : pr.repository.webUrl, + }, + isCrossRepository: pr.forkSource != null, + }, + pr.isDraft, + undefined, + undefined, + undefined, + undefined, + getAzurePullRequestReviewDecision(pr.reviewers?.filter(r => r.isRequired).map(r => r.vote ?? 0) ?? []), + pr.reviewers.filter(r => r.vote == null || r.vote === 0).map(r => fromAzureUserWithVoteToReviewer(r)), + pr.reviewers.filter(r => r.vote != null && r.vote !== 0).map(r => fromAzureUserWithVoteToReviewer(r)), + pr.reviewers.map(r => fromAzureReviewerToPullRequestMember(r)), + undefined, + { + id: pr.repository?.project?.id, + name: projectName, + resourceId: '', // TODO: This is a workaround until we can get the org id here. + resourceName: orgName, + }, + ); +} diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index ec9a16d6546f7..f8b62e06828db 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -255,11 +255,13 @@ 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, id, { + baseUrl: this.apiBaseUrl, + }); } protected override async getProviderIssue( @@ -271,15 +273,17 @@ export class AzureDevOpsIntegration extends HostingIntegration< } protected override async getProviderPullRequestForBranch( - _session: AuthenticationSession, - _repo: AzureRepositoryDescriptor, - _branch: string, + { accessToken }: AuthenticationSession, + repo: AzureRepositoryDescriptor, + branch: string, _options?: { avatarSize?: number; include?: PullRequestState[]; }, ): Promise { - return Promise.resolve(undefined); + return (await this.container.azure)?.getPullRequestForBranch(this, accessToken, repo.owner, repo.name, branch, { + baseUrl: this.apiBaseUrl, + }); } protected override async getProviderPullRequestForCommit(