Skip to content

Commit aaeda35

Browse files
committed
WIP: Sends request Azure for an issue or PR and prints the result
(#3977)
1 parent f6d03e9 commit aaeda35

File tree

3 files changed

+332
-4
lines changed

3 files changed

+332
-4
lines changed

Diff for: src/container.ts

+23
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) {

Diff for: src/plus/integrations/providers/azure/azure.ts

+296
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import type { AzurePullRequestEntityIdentifierInput } from '@gitkraken/provider-apis';
2+
import type { HttpsProxyAgent } from 'https-proxy-agent';
3+
import type { CancellationToken, Disposable, Event } from 'vscode';
4+
import { window } from 'vscode';
5+
import type { RequestInit, Response } from '@env/fetch';
6+
import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch';
7+
import { isWeb } from '@env/platform';
8+
import type { Container } from '../../../../container';
9+
import {
10+
AuthenticationError,
11+
AuthenticationErrorReason,
12+
CancellationError,
13+
ProviderFetchError,
14+
RequestClientError,
15+
RequestNotFoundError,
16+
RequestRateLimitError,
17+
} from '../../../../errors';
18+
import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest';
19+
import type { Provider } from '../../../../git/models/remoteProvider';
20+
import {
21+
showIntegrationRequestFailed500WarningMessage,
22+
showIntegrationRequestTimedOutWarningMessage,
23+
} from '../../../../messages';
24+
import { configuration } from '../../../../system/-webview/configuration';
25+
import { debug } from '../../../../system/decorators/log';
26+
import { Logger } from '../../../../system/logger';
27+
import type { LogScope } from '../../../../system/logger.scope';
28+
import { getLogScope } from '../../../../system/logger.scope';
29+
import { maybeStopWatch } from '../../../../system/stopwatch';
30+
31+
export class AzureDevOpsApi implements Disposable {
32+
private readonly _disposable: Disposable;
33+
34+
constructor(_container: Container) {
35+
this._disposable = configuration.onDidChangeAny(e => {
36+
if (
37+
configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) ||
38+
configuration.changed(e, ['outputLevel', 'proxy'])
39+
) {
40+
this.resetCaches();
41+
}
42+
});
43+
}
44+
45+
dispose(): void {
46+
this._disposable.dispose();
47+
}
48+
49+
private _proxyAgent: HttpsProxyAgent | null | undefined = null;
50+
private get proxyAgent(): HttpsProxyAgent | undefined {
51+
if (isWeb) return undefined;
52+
53+
if (this._proxyAgent === null) {
54+
this._proxyAgent = getProxyAgent();
55+
}
56+
return this._proxyAgent;
57+
}
58+
59+
private resetCaches(): void {
60+
this._proxyAgent = null;
61+
// this._defaults.clear();
62+
// this._enterpriseVersions.clear();
63+
}
64+
65+
@debug<AzureDevOpsApi['getIssueOrPullRequest']>({ args: { 0: p => p.name, 1: '<token>' } })
66+
public async getIssueOrPullRequest(
67+
provider: Provider,
68+
token: string,
69+
owner: string,
70+
repo: string,
71+
number: number,
72+
options: {
73+
baseUrl: string;
74+
},
75+
): Promise<IssueOrPullRequest | undefined> {
76+
const scope = getLogScope();
77+
const [projectName, _, repoName] = repo.split('/');
78+
79+
try {
80+
interface ResultAzureUser {
81+
displayName: string;
82+
url: string;
83+
_links: {
84+
avatar: {
85+
href: string;
86+
};
87+
};
88+
id: string;
89+
uniqueName: string;
90+
imageUrl: string;
91+
descriptor: string;
92+
}
93+
interface WorkItemResult {
94+
_links: {
95+
fields: {
96+
href: string;
97+
};
98+
html: {
99+
href: string;
100+
};
101+
self: {
102+
href: string;
103+
};
104+
workItemComments: {
105+
href: string;
106+
};
107+
workItemRevisions: {
108+
href: string;
109+
};
110+
workItemType: {
111+
href: string;
112+
};
113+
workItemUpdates: {
114+
href: string;
115+
};
116+
};
117+
fields: {
118+
'System.AreaPath': string;
119+
'System.TeamProject': string;
120+
'System.IterationPath': string;
121+
'System.WorkItemType': string;
122+
'System.State': string;
123+
'System.Reason': string;
124+
'System.CreatedDate': string;
125+
'System.CreatedBy': ResultAzureUser;
126+
'System.ChangedDate': string;
127+
'System.ChangedBy': ResultAzureUser;
128+
'System.CommentCount': number;
129+
'System.Title': string;
130+
'Microsoft.VSTS.Common.StateChangeDate': string;
131+
'Microsoft.VSTS.Common.Priority': number;
132+
'Microsoft.VSTS.Common.Severity': string;
133+
'Microsoft.VSTS.Common.ValueArea': string;
134+
};
135+
id: number;
136+
rev: number;
137+
url: string;
138+
}
139+
// Try to get the Work item (wit) first with specific fields
140+
const witResult = await this.request<WorkItemResult>(
141+
provider,
142+
token,
143+
options?.baseUrl,
144+
`${owner}/${projectName}/_apis/wit/workItems/${number}?fields=System.Title`,
145+
{
146+
method: 'GET',
147+
},
148+
scope,
149+
);
150+
151+
if (witResult == null) {
152+
// If the Work item does not exist, try to get the PR
153+
const prResult = await this.request<unknown>(
154+
provider,
155+
token,
156+
options?.baseUrl,
157+
`${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests/${number}`,
158+
{
159+
method: 'GET',
160+
},
161+
scope,
162+
);
163+
console.log(prResult);
164+
} else {
165+
console.log(witResult);
166+
}
167+
168+
//if (result?.repository?.issueOrPullRequest == null) return undefined;
169+
return undefined;
170+
//return result.repository.issueOrPullRequest as IssueOrPullRequest;
171+
} catch (ex) {
172+
Logger.error(ex, scope);
173+
return undefined;
174+
}
175+
}
176+
177+
private async request<T>(
178+
provider: Provider,
179+
token: string,
180+
baseUrl: string,
181+
route: string,
182+
options: { method: RequestInit['method'] } & Record<string, unknown>,
183+
scope: LogScope | undefined,
184+
cancellation?: CancellationToken | undefined,
185+
): Promise<T | undefined> {
186+
const url = `${baseUrl}/${route}`;
187+
188+
let rsp: Response;
189+
try {
190+
const sw = maybeStopWatch(`[AZURE] ${options?.method ?? 'GET'} ${url}`, { log: false });
191+
const agent = this.proxyAgent;
192+
193+
try {
194+
let aborter: AbortController | undefined;
195+
if (cancellation != null) {
196+
if (cancellation.isCancellationRequested) throw new CancellationError();
197+
198+
aborter = new AbortController();
199+
cancellation.onCancellationRequested(() => aborter!.abort());
200+
}
201+
202+
rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () =>
203+
fetch(url, {
204+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
205+
agent: agent,
206+
signal: aborter?.signal,
207+
...options,
208+
}),
209+
);
210+
211+
if (rsp.ok) {
212+
const data: T = await rsp.json();
213+
return data;
214+
}
215+
216+
throw new ProviderFetchError('AzureDevOps', rsp);
217+
} finally {
218+
sw?.stop();
219+
}
220+
} catch (ex) {
221+
if (ex instanceof ProviderFetchError || ex.name === 'AbortError') {
222+
this.handleRequestError(provider, token, ex, scope);
223+
} else if (Logger.isDebugging) {
224+
void window.showErrorMessage(`AzureDevOps request failed: ${ex.message}`);
225+
}
226+
227+
throw ex;
228+
}
229+
}
230+
231+
private handleRequestError(
232+
provider: Provider | undefined,
233+
_token: string,
234+
ex: ProviderFetchError | (Error & { name: 'AbortError' }),
235+
scope: LogScope | undefined,
236+
): void {
237+
if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex);
238+
239+
switch (ex.status) {
240+
case 404: // Not found
241+
case 410: // Gone
242+
case 422: // Unprocessable Entity
243+
throw new RequestNotFoundError(ex);
244+
case 401: // Unauthorized
245+
throw new AuthenticationError('azureDevOps', AuthenticationErrorReason.Unauthorized, ex);
246+
// TODO: Learn the Azure API docs and put it in order:
247+
// case 403: // Forbidden
248+
// if (ex.message.includes('rate limit')) {
249+
// let resetAt: number | undefined;
250+
251+
// const reset = ex.response?.headers?.get('x-ratelimit-reset');
252+
// if (reset != null) {
253+
// resetAt = parseInt(reset, 10);
254+
// if (Number.isNaN(resetAt)) {
255+
// resetAt = undefined;
256+
// }
257+
// }
258+
259+
// throw new RequestRateLimitError(ex, token, resetAt);
260+
// }
261+
// throw new AuthenticationError('azure', AuthenticationErrorReason.Forbidden, ex);
262+
case 500: // Internal Server Error
263+
Logger.error(ex, scope);
264+
if (ex.response != null) {
265+
provider?.trackRequestException();
266+
void showIntegrationRequestFailed500WarningMessage(
267+
`${provider?.name ?? 'AzureDevOps'} failed to respond and might be experiencing issues.${
268+
provider == null || provider.id === 'azure'
269+
? ' Please visit the [AzureDevOps status page](https://status.dev.azure.com) for more information.'
270+
: ''
271+
}`,
272+
);
273+
}
274+
return;
275+
case 502: // Bad Gateway
276+
Logger.error(ex, scope);
277+
// TODO: Learn the Azure API docs and put it in order:
278+
// if (ex.message.includes('timeout')) {
279+
// provider?.trackRequestException();
280+
// void showIntegrationRequestTimedOutWarningMessage(provider?.name ?? 'Azure');
281+
// return;
282+
// }
283+
break;
284+
default:
285+
if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex);
286+
break;
287+
}
288+
289+
Logger.error(ex, scope);
290+
if (Logger.isDebugging) {
291+
void window.showErrorMessage(
292+
`AzureDevOps request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`,
293+
);
294+
}
295+
}
296+
}

Diff for: src/plus/integrations/providers/azureDevOps.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,20 @@ export class AzureDevOpsIntegration extends HostingIntegration<
101101
}
102102

103103
protected override async getProviderIssueOrPullRequest(
104-
_session: AuthenticationSession,
105-
_repo: AzureRepositoryDescriptor,
106-
_id: string,
104+
{ accessToken }: AuthenticationSession,
105+
repo: AzureRepositoryDescriptor,
106+
id: string,
107107
): Promise<IssueOrPullRequest | undefined> {
108-
return Promise.resolve(undefined);
108+
return (await this.container.azure)?.getIssueOrPullRequest(
109+
this,
110+
accessToken,
111+
repo.owner,
112+
repo.name,
113+
Number(id),
114+
{
115+
baseUrl: this.apiBaseUrl,
116+
},
117+
);
109118
}
110119

111120
protected override async getProviderIssue(

0 commit comments

Comments
 (0)