Skip to content

Commit 84aad1b

Browse files
committed
Retrieves an issue from Azure
(#3977, #3996)
1 parent 22796d9 commit 84aad1b

File tree

4 files changed

+306
-4
lines changed

4 files changed

+306
-4
lines changed

src/container.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type { CloudIntegrationService } from './plus/integrations/authentication
3535
import { ConfiguredIntegrationService } from './plus/integrations/authentication/configuredIntegrationService';
3636
import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthenticationService';
3737
import { IntegrationService } from './plus/integrations/integrationService';
38+
import type { AzureDevOpsApi } from './plus/integrations/providers/azure/azure';
3839
import type { GitHubApi } from './plus/integrations/providers/github/github';
3940
import type { GitLabApi } from './plus/integrations/providers/gitlab/gitlab';
4041
import { EnrichmentService } from './plus/launchpad/enrichmentService';
@@ -477,6 +478,28 @@ export class Container {
477478
return this._git;
478479
}
479480

481+
private _azure: Promise<AzureDevOpsApi | undefined> | undefined;
482+
get azure(): Promise<AzureDevOpsApi | undefined> {
483+
if (this._azure == null) {
484+
async function load(this: Container) {
485+
try {
486+
const azure = new (
487+
await import(/* webpackChunkName: "integrations" */ './plus/integrations/providers/azure/azure')
488+
).AzureDevOpsApi(this);
489+
this._disposables.push(azure);
490+
return azure;
491+
} catch (ex) {
492+
Logger.error(ex);
493+
return undefined;
494+
}
495+
}
496+
497+
this._azure = load.call(this);
498+
}
499+
500+
return this._azure;
501+
}
502+
480503
private _github: Promise<GitHubApi | undefined> | undefined;
481504
get github(): Promise<GitHubApi | undefined> {
482505
if (this._github == null) {
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import type { HttpsProxyAgent } from 'https-proxy-agent';
2+
import type { CancellationToken, Disposable } from 'vscode';
3+
import { window } from 'vscode';
4+
import type { RequestInit, Response } from '@env/fetch';
5+
import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch';
6+
import { isWeb } from '@env/platform';
7+
import type { Container } from '../../../../container';
8+
import {
9+
AuthenticationError,
10+
AuthenticationErrorReason,
11+
CancellationError,
12+
ProviderFetchError,
13+
RequestClientError,
14+
RequestNotFoundError,
15+
} from '../../../../errors';
16+
import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest';
17+
import type { Provider } from '../../../../git/models/remoteProvider';
18+
import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages';
19+
import { configuration } from '../../../../system/-webview/configuration';
20+
import { debug } from '../../../../system/decorators/log';
21+
import { Logger } from '../../../../system/logger';
22+
import type { LogScope } from '../../../../system/logger.scope';
23+
import { getLogScope } from '../../../../system/logger.scope';
24+
import { maybeStopWatch } from '../../../../system/stopwatch';
25+
import type { WorkItem } from './models';
26+
27+
export class AzureDevOpsApi implements Disposable {
28+
private readonly _disposable: Disposable;
29+
30+
constructor(_container: Container) {
31+
this._disposable = configuration.onDidChangeAny(e => {
32+
if (
33+
configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) ||
34+
configuration.changed(e, ['outputLevel', 'proxy'])
35+
) {
36+
this.resetCaches();
37+
}
38+
});
39+
}
40+
41+
dispose(): void {
42+
this._disposable.dispose();
43+
}
44+
45+
private _proxyAgent: HttpsProxyAgent | null | undefined = null;
46+
private get proxyAgent(): HttpsProxyAgent | undefined {
47+
if (isWeb) return undefined;
48+
49+
if (this._proxyAgent === null) {
50+
this._proxyAgent = getProxyAgent();
51+
}
52+
return this._proxyAgent;
53+
}
54+
55+
private resetCaches(): void {
56+
this._proxyAgent = null;
57+
// this._defaults.clear();
58+
// this._enterpriseVersions.clear();
59+
}
60+
61+
@debug<AzureDevOpsApi['getIssueOrPullRequest']>({ args: { 0: p => p.name, 1: '<token>' } })
62+
public async getIssueOrPullRequest(
63+
provider: Provider,
64+
token: string,
65+
owner: string,
66+
repo: string,
67+
id: string,
68+
options: {
69+
baseUrl: string;
70+
},
71+
): Promise<IssueOrPullRequest | undefined> {
72+
const scope = getLogScope();
73+
const [projectName] = repo.split('/');
74+
75+
try {
76+
// Try to get the Work item (wit) first with specific fields
77+
const issueResult = await this.request<WorkItem>(
78+
provider,
79+
token,
80+
options?.baseUrl,
81+
`${owner}/${projectName}/_apis/wit/workItems/${id}`,
82+
{
83+
method: 'GET',
84+
},
85+
scope,
86+
);
87+
88+
if (issueResult != null) {
89+
return {
90+
id: issueResult.id.toString(),
91+
type: 'issue',
92+
nodeId: issueResult.id.toString(),
93+
provider: provider,
94+
createdDate: new Date(issueResult.fields['System.CreatedDate']),
95+
updatedDate: new Date(issueResult.fields['System.ChangedDate']),
96+
state: issueResult.fields['System.State'] === 'Closed' ? 'closed' : 'opened',
97+
closed: issueResult.fields['System.State'] === 'Closed',
98+
title: issueResult.fields['System.Title'],
99+
url: issueResult._links.html.href,
100+
};
101+
}
102+
103+
return undefined;
104+
} catch (ex) {
105+
Logger.error(ex, scope);
106+
return undefined;
107+
}
108+
}
109+
110+
private async request<T>(
111+
provider: Provider,
112+
token: string,
113+
baseUrl: string,
114+
route: string,
115+
options: { method: RequestInit['method'] } & Record<string, unknown>,
116+
scope: LogScope | undefined,
117+
cancellation?: CancellationToken | undefined,
118+
): Promise<T | undefined> {
119+
const url = `${baseUrl}/${route}`;
120+
121+
let rsp: Response;
122+
try {
123+
const sw = maybeStopWatch(`[AZURE] ${options?.method ?? 'GET'} ${url}`, { log: false });
124+
const agent = this.proxyAgent;
125+
126+
try {
127+
let aborter: AbortController | undefined;
128+
if (cancellation != null) {
129+
if (cancellation.isCancellationRequested) throw new CancellationError();
130+
131+
aborter = new AbortController();
132+
cancellation.onCancellationRequested(() => aborter!.abort());
133+
}
134+
135+
rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () =>
136+
fetch(url, {
137+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
138+
agent: agent,
139+
signal: aborter?.signal,
140+
...options,
141+
}),
142+
);
143+
144+
if (rsp.ok) {
145+
const data: T = await rsp.json();
146+
return data;
147+
}
148+
149+
throw new ProviderFetchError('AzureDevOps', rsp);
150+
} finally {
151+
sw?.stop();
152+
}
153+
} catch (ex) {
154+
if (ex instanceof ProviderFetchError || ex.name === 'AbortError') {
155+
this.handleRequestError(provider, token, ex, scope);
156+
} else if (Logger.isDebugging) {
157+
void window.showErrorMessage(`AzureDevOps request failed: ${ex.message}`);
158+
}
159+
160+
throw ex;
161+
}
162+
}
163+
164+
private handleRequestError(
165+
provider: Provider | undefined,
166+
_token: string,
167+
ex: ProviderFetchError | (Error & { name: 'AbortError' }),
168+
scope: LogScope | undefined,
169+
): void {
170+
if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex);
171+
172+
switch (ex.status) {
173+
case 404: // Not found
174+
case 410: // Gone
175+
case 422: // Unprocessable Entity
176+
throw new RequestNotFoundError(ex);
177+
case 401: // Unauthorized
178+
throw new AuthenticationError('azureDevOps', AuthenticationErrorReason.Unauthorized, ex);
179+
// TODO: Learn the Azure API docs and put it in order:
180+
// case 403: // Forbidden
181+
// if (ex.message.includes('rate limit')) {
182+
// let resetAt: number | undefined;
183+
184+
// const reset = ex.response?.headers?.get('x-ratelimit-reset');
185+
// if (reset != null) {
186+
// resetAt = parseInt(reset, 10);
187+
// if (Number.isNaN(resetAt)) {
188+
// resetAt = undefined;
189+
// }
190+
// }
191+
192+
// throw new RequestRateLimitError(ex, token, resetAt);
193+
// }
194+
// throw new AuthenticationError('azure', AuthenticationErrorReason.Forbidden, ex);
195+
case 500: // Internal Server Error
196+
Logger.error(ex, scope);
197+
if (ex.response != null) {
198+
provider?.trackRequestException();
199+
void showIntegrationRequestFailed500WarningMessage(
200+
`${provider?.name ?? 'AzureDevOps'} failed to respond and might be experiencing issues.${
201+
provider == null || provider.id === 'azure'
202+
? ' Please visit the [AzureDevOps status page](https://status.dev.azure.com) for more information.'
203+
: ''
204+
}`,
205+
);
206+
}
207+
return;
208+
case 502: // Bad Gateway
209+
Logger.error(ex, scope);
210+
// TODO: Learn the Azure API docs and put it in order:
211+
// if (ex.message.includes('timeout')) {
212+
// provider?.trackRequestException();
213+
// void showIntegrationRequestTimedOutWarningMessage(provider?.name ?? 'Azure');
214+
// return;
215+
// }
216+
break;
217+
default:
218+
if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex);
219+
break;
220+
}
221+
222+
Logger.error(ex, scope);
223+
if (Logger.isDebugging) {
224+
void window.showErrorMessage(
225+
`AzureDevOps request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`,
226+
);
227+
}
228+
}
229+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export interface AzureLink {
2+
href: string;
3+
}
4+
5+
export interface AzureUser {
6+
displayName: string;
7+
url: string;
8+
_links: {
9+
avatar: AzureLink;
10+
};
11+
id: string;
12+
uniqueName: string;
13+
imageUrl: string;
14+
descriptor: string;
15+
}
16+
17+
export interface WorkItem {
18+
_links: {
19+
fields: AzureLink;
20+
html: AzureLink;
21+
self: AzureLink;
22+
workItemComments: AzureLink;
23+
workItemRevisions: AzureLink;
24+
workItemType: AzureLink;
25+
workItemUpdates: AzureLink;
26+
};
27+
fields: {
28+
// 'System.AreaPath': string;
29+
// 'System.TeamProject': string;
30+
// 'System.IterationPath': string;
31+
'System.WorkItemType': string;
32+
'System.State': string;
33+
// 'System.Reason': string;
34+
'System.CreatedDate': string;
35+
// 'System.CreatedBy': AzureUser;
36+
'System.ChangedDate': string;
37+
// 'System.ChangedBy': AzureUser;
38+
// 'System.CommentCount': number;
39+
'System.Title': string;
40+
// 'Microsoft.VSTS.Common.StateChangeDate': string;
41+
// 'Microsoft.VSTS.Common.Priority': number;
42+
// 'Microsoft.VSTS.Common.Severity': string;
43+
// 'Microsoft.VSTS.Common.ValueArea': string;
44+
};
45+
id: number;
46+
rev: number;
47+
url: string;
48+
}

src/plus/integrations/providers/azureDevOps.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,13 @@ export class AzureDevOpsIntegration extends HostingIntegration<
255255
}
256256

257257
protected override async getProviderIssueOrPullRequest(
258-
_session: AuthenticationSession,
259-
_repo: AzureRepositoryDescriptor,
260-
_id: string,
258+
{ accessToken }: AuthenticationSession,
259+
repo: AzureRepositoryDescriptor,
260+
id: string,
261261
): Promise<IssueOrPullRequest | undefined> {
262-
return Promise.resolve(undefined);
262+
return (await this.container.azure)?.getIssueOrPullRequest(this, accessToken, repo.owner, repo.name, id, {
263+
baseUrl: this.apiBaseUrl,
264+
});
263265
}
264266

265267
protected override async getProviderIssue(

0 commit comments

Comments
 (0)