Skip to content

Commit

Permalink
Retrieves an issue from Azure
Browse files Browse the repository at this point in the history
  • Loading branch information
sergeibbb committed Jan 29, 2025
1 parent e49fab8 commit 07e5cf7
Show file tree
Hide file tree
Showing 4 changed files with 313 additions and 4 deletions.
23 changes: 23 additions & 0 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -477,6 +478,28 @@ export class Container {
return this._git;
}

private _azure: Promise<AzureDevOpsApi | undefined> | undefined;
get azure(): Promise<AzureDevOpsApi | undefined> {
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<GitHubApi | undefined> | undefined;
get github(): Promise<GitHubApi | undefined> {
if (this._github == null) {
Expand Down
229 changes: 229 additions & 0 deletions src/plus/integrations/providers/azure/azure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
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 { 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 { WorkItem } from './models';

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<AzureDevOpsApi['getIssueOrPullRequest']>({ args: { 0: p => p.name, 1: '<token>' } })
public async getIssueOrPullRequest(
provider: Provider,
token: string,
owner: string,
repo: string,
number: number,
options: {
baseUrl: string;
},
): Promise<IssueOrPullRequest | undefined> {
const scope = getLogScope();
const [projectName] = repo.split('/');

try {
// Try to get the Work item (wit) first with specific fields
const issueResult = await this.request<WorkItem>(
provider,
token,
options?.baseUrl,
`${owner}/${projectName}/_apis/wit/workItems/${number}`,
{
method: 'GET',
},
scope,
);

if (issueResult != null) {
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: issueResult.fields['System.State'] === 'Closed' ? 'closed' : 'opened',
closed: issueResult.fields['System.State'] === 'Closed',
title: issueResult.fields['System.Title'],
url: issueResult._links.html.href,
};
}

return undefined;
} catch (ex) {
Logger.error(ex, scope);
return undefined;
}
}

private async request<T>(
provider: Provider,
token: string,
baseUrl: string,
route: string,
options: { method: RequestInit['method'] } & Record<string, unknown>,
scope: LogScope | undefined,
cancellation?: CancellationToken | undefined,
): Promise<T | undefined> {
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}`,
);
}
}
}
48 changes: 48 additions & 0 deletions src/plus/integrations/providers/azure/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 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;
}
17 changes: 13 additions & 4 deletions src/plus/integrations/providers/azureDevOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IssueOrPullRequest | undefined> {
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(
Expand Down

0 comments on commit 07e5cf7

Please sign in to comment.