Skip to content

Commit c3cf1ff

Browse files
committed
Generates create AzureDevOps PR URLs and retrieves repoId for cross-forks
(#4142, #4143)
1 parent 7804bbf commit c3cf1ff

File tree

8 files changed

+175
-10
lines changed

8 files changed

+175
-10
lines changed

src/git/remotes/azure-devops.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { Range, Uri } from 'vscode';
22
import type { AutolinkReference, DynamicAutolinkReference } from '../../autolinks/models/autolinks';
3+
import type { Container } from '../../container';
4+
import { HostingIntegration } from '../../plus/integrations/integration';
5+
import { remoteProviderIdToIntegrationId } from '../../plus/integrations/integrationService';
6+
import { parseAzureHttpsUrl } from '../../plus/integrations/providers/azure/models';
37
import type { Brand, Unbrand } from '../../system/brand';
48
import type { Repository } from '../models/repository';
59
import type { GkProviderId } from '../models/repositoryIdentities';
@@ -17,7 +21,14 @@ const rangeRegex = /line=(\d+)(?:&lineEnd=(\d+))?/;
1721

1822
export class AzureDevOpsRemote extends RemoteProvider {
1923
private readonly project: string | undefined;
20-
constructor(domain: string, path: string, protocol?: string, name?: string, legacy: boolean = false) {
24+
constructor(
25+
private readonly container: Container,
26+
domain: string,
27+
path: string,
28+
protocol?: string,
29+
name?: string,
30+
legacy: boolean = false,
31+
) {
2132
let repoProject;
2233
if (sshDomainRegex.test(domain)) {
2334
path = path.replace(sshPathRegex, '');
@@ -182,8 +193,38 @@ export class AzureDevOpsRemote extends RemoteProvider {
182193
return this.encodeUrl(`${this.baseUrl}/commit/${sha}`);
183194
}
184195

185-
protected override getUrlForComparison(base: string, compare: string, _notation: '..' | '...'): string {
186-
return this.encodeUrl(`${this.baseUrl}/branchCompare?baseVersion=GB${base}&targetVersion=GB${compare}`);
196+
protected override getUrlForComparison(base: string, head: string, _notation: '..' | '...'): string {
197+
return this.encodeUrl(`${this.baseUrl}/branchCompare?baseVersion=GB${base}&targetVersion=GB${head}`);
198+
}
199+
200+
protected override async getUrlForCreatePullRequest(
201+
base: { branch?: string; remote: { path: string; url: string } },
202+
head: { branch: string; remote: { path: string; url: string } },
203+
): Promise<string | undefined> {
204+
const query = new URLSearchParams({ sourceRef: head.branch, targetRef: base.branch ?? '' });
205+
if (base.remote.url !== head.remote.url) {
206+
const parsedBaseUrl = parseAzureUrl(base.remote.url);
207+
if (parsedBaseUrl == null) {
208+
return undefined;
209+
}
210+
const { org: baseOrg, project: baseProject, repo: baseName } = parsedBaseUrl;
211+
const targetDesc = { project: baseProject, name: baseName, owner: baseOrg };
212+
213+
const integrationId = remoteProviderIdToIntegrationId(this.id);
214+
const integration = integrationId && (await this.container.integrations.get(integrationId));
215+
let targetRepoId = undefined;
216+
if (integration?.isConnected && integration instanceof HostingIntegration) {
217+
targetRepoId = (await integration.getRepoInfo?.(targetDesc))?.id;
218+
}
219+
220+
if (!targetRepoId) {
221+
return undefined;
222+
}
223+
query.set('targetRepositoryId', targetRepoId);
224+
// query.set('sourceRepositoryId', compare.repoId); // ?? looks like not needed
225+
}
226+
227+
return `${this.encodeUrl(`${this.getRepoBaseUrl(head.remote.path)}/pullrequestcreate`)}?${query.toString()}`;
187228
}
188229

189230
protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string {
@@ -207,3 +248,22 @@ export class AzureDevOpsRemote extends RemoteProvider {
207248
return this.encodeUrl(`${this.baseUrl}?path=/${fileName}${line}`);
208249
}
209250
}
251+
252+
const azureSshUrlRegex = /^(?:[^@]+@)?([^:]+):v\d\//;
253+
function parseAzureUrl(url: string): { org: string; project: string; repo: string } | undefined {
254+
if (azureSshUrlRegex.test(url)) {
255+
// Examples of SSH urls:
256+
// - old one: [email protected]:v3/bbbchiv/MyFirstProject/test
257+
// - modern one: [email protected]:v3/bbbchiv2/MyFirstProject/test
258+
url = url.replace(azureSshUrlRegex, '');
259+
const match = orgAndProjectRegex.exec(url);
260+
if (match != null) {
261+
const [, org, project, rest] = match;
262+
return { org: org, project: project, repo: rest };
263+
}
264+
} else {
265+
const [org, project, rest] = parseAzureHttpsUrl(url);
266+
return { org: org, project: project, repo: rest };
267+
}
268+
return undefined;
269+
}

src/git/remotes/remoteProvider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,11 @@ export abstract class RemoteProvider<T extends ResourceDescriptor = ResourceDesc
159159
}
160160

161161
protected get baseUrl(): string {
162-
return `${this.protocol}://${this.domain}/${this.path}`;
162+
return this.getRepoBaseUrl(this.path);
163+
}
164+
165+
protected getRepoBaseUrl(path: string): string {
166+
return `${this.protocol}://${this.domain}/${path}`;
163167
}
164168

165169
protected formatName(name: string): string {

src/git/remotes/remoteProviders.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const builtInProviders: RemoteProviders = [
4141
{
4242
custom: false,
4343
matcher: /\bdev\.azure\.com$/i,
44-
creator: (_container: Container, domain: string, path: string) => new AzureDevOpsRemote(domain, path),
44+
creator: (container: Container, domain: string, path: string) => new AzureDevOpsRemote(container, domain, path),
4545
},
4646
{
4747
custom: true,
@@ -56,8 +56,8 @@ const builtInProviders: RemoteProviders = [
5656
{
5757
custom: false,
5858
matcher: /\bvisualstudio\.com$/i,
59-
creator: (_container: Container, domain: string, path: string) =>
60-
new AzureDevOpsRemote(domain, path, undefined, undefined, true),
59+
creator: (container: Container, domain: string, path: string) =>
60+
new AzureDevOpsRemote(container, domain, path, undefined, undefined, true),
6161
},
6262
{
6363
custom: false,
@@ -136,8 +136,8 @@ export function loadRemoteProviders(
136136
function getCustomProviderCreator(cfg: RemotesConfig) {
137137
switch (cfg.type) {
138138
case 'AzureDevOps':
139-
return (_container: Container, domain: string, path: string) =>
140-
new AzureDevOpsRemote(domain, path, cfg.protocol, cfg.name, true);
139+
return (container: Container, domain: string, path: string) =>
140+
new AzureDevOpsRemote(container, domain, path, cfg.protocol, cfg.name, true);
141141
case 'Bitbucket':
142142
return (_container: Container, domain: string, path: string) =>
143143
new BitbucketRemote(domain, path, cfg.protocol, cfg.name, true);

src/plus/integrations/integration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type {
4444
ProviderPullRequest,
4545
ProviderRepoInput,
4646
ProviderReposInput,
47+
ProviderRepository,
4748
} from './providers/models';
4849
import { IssueFilter, PagingMode, PullRequestFilter } from './providers/models';
4950
import type { ProvidersApi } from './providers/providersApi';
@@ -785,6 +786,8 @@ export abstract class HostingIntegration<
785786
return defaultBranch;
786787
}
787788

789+
getRepoInfo?(repo: { owner: string; name: string; project?: string }): Promise<ProviderRepository | undefined>;
790+
788791
protected abstract getProviderDefaultBranch(
789792
{ accessToken }: ProviderAuthenticationSession,
790793
repo: T,

src/plus/integrations/providers/azure/models.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,39 @@ export function getAzureRepo(pr: AzurePullRequest): string {
337337
return `${pr.repository.project.name}/_git/${pr.repository.name}`;
338338
}
339339

340+
// Example: https://bbbchiv.visualstudio.com/MyFirstProject/_git/test
341+
const azureProjectRepoRegex = /([^/]+)\/_git\/([^/]+)/;
342+
function parseVstsHttpsUrl(url: URL): [owner: string, project: string, repo: string] {
343+
const owner = getVSTSOwner(url);
344+
const match = azureProjectRepoRegex.exec(url.pathname);
345+
if (match == null) {
346+
throw new Error(`Invalid VSTS URL: ${url.toString()}`);
347+
}
348+
const [, project, repo] = match;
349+
return [owner, project, repo];
350+
}
351+
352+
// Example https://bbbchiv2@dev.azure.com/bbbchiv2/MyFirstProject/_git/test
353+
const azureHttpsUrlRegex = /([^/]+)\/([^/]+)\/_git\/([^/]+)/;
354+
function parseAzureNewStyleUrl(url: URL): [owner: string, project: string, repo: string] {
355+
const match = azureHttpsUrlRegex.exec(url.pathname);
356+
if (match == null) {
357+
throw new Error(`Invalid Azure URL: ${url.toString()}`);
358+
}
359+
const [, owner, project, repo] = match;
360+
return [owner, project, repo];
361+
}
362+
363+
export function parseAzureHttpsUrl(url: string): [owner: string, project: string, repo: string];
364+
export function parseAzureHttpsUrl(urlObj: URL): [owner: string, project: string, repo: string];
365+
export function parseAzureHttpsUrl(arg: URL | string): [owner: string, project: string, repo: string] {
366+
const url = typeof arg === 'string' ? new URL(arg) : arg;
367+
if (vstsHostnameRegex.test(url.hostname)) {
368+
return parseVstsHttpsUrl(url);
369+
}
370+
return parseAzureNewStyleUrl(url);
371+
}
372+
340373
export function getAzurePullRequestWebUrl(pr: AzurePullRequest): string {
341374
const url = new URL(pr.url);
342375
const baseUrl = new URL(url.origin).toString();

src/plus/integrations/providers/azureDevOps.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
AzureRemoteRepositoryDescriptor,
1818
AzureRepositoryDescriptor,
1919
} from './azure/models';
20-
import type { ProviderPullRequest } from './models';
20+
import type { ProviderPullRequest, ProviderRepository } from './models';
2121
import { fromProviderIssue, fromProviderPullRequest, providersMetadata } from './models';
2222

2323
const metadata = providersMetadata[HostingIntegrationId.AzureDevOps];
@@ -300,6 +300,17 @@ export class AzureDevOpsIntegration extends HostingIntegration<
300300
return Promise.resolve(undefined);
301301
}
302302

303+
public override async getRepoInfo(repo: {
304+
owner: string;
305+
name: string;
306+
project: string;
307+
}): Promise<ProviderRepository | undefined> {
308+
const api = await this.getProvidersApi();
309+
return api.getRepo(this.id, repo.owner, repo.name, repo.project, {
310+
accessToken: this._session?.accessToken,
311+
});
312+
}
313+
303314
protected override async getProviderRepositoryMetadata(
304315
_session: AuthenticationSession,
305316
_repo: AzureRepositoryDescriptor,

src/plus/integrations/providers/models.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ export interface PageInfo {
226226
nextPage?: number | null;
227227
}
228228

229+
export type GetRepoFn = (
230+
input: ProviderRepoInput,
231+
options?: EnterpriseOptions,
232+
) => Promise<{ data: ProviderRepository }>;
233+
export type GetRepoOfProjectFn = (
234+
input: ProviderRepoInput & { project: string },
235+
options?: EnterpriseOptions,
236+
) => Promise<{ data: ProviderRepository }>;
237+
229238
export type GetPullRequestsForReposFn = (
230239
input: (GetPullRequestsForReposInput | GetPullRequestsForRepoIdsInput) & PagingInput,
231240
options?: EnterpriseOptions,
@@ -350,6 +359,8 @@ export type GetIssuesForResourceForCurrentUserFn = (
350359

351360
export interface ProviderInfo extends ProviderMetadata {
352361
provider: GitHub | GitLab | Bitbucket | Jira | Trello | AzureDevOps;
362+
getRepoFn?: GetRepoFn;
363+
getRepoOfProjectFn?: GetRepoOfProjectFn;
353364
getPullRequestsForReposFn?: GetPullRequestsForReposFn;
354365
getPullRequestsForRepoFn?: GetPullRequestsForRepoFn;
355366
getPullRequestsForUserFn?: GetPullRequestsForUserFn;

src/plus/integrations/providers/providersApi.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export class ProvidersApi {
215215
[HostingIntegrationId.AzureDevOps]: {
216216
...providersMetadata[HostingIntegrationId.AzureDevOps],
217217
provider: providerApis.azureDevOps,
218+
getRepoOfProjectFn: providerApis.azureDevOps.getRepo.bind(providerApis.azureDevOps),
218219
getCurrentUserFn: providerApis.azureDevOps.getCurrentUser.bind(
219220
providerApis.azureDevOps,
220221
) as GetCurrentUserFn,
@@ -442,6 +443,48 @@ export class ProvidersApi {
442443
}
443444
}
444445

446+
async getRepo(
447+
providerId: IntegrationId,
448+
owner: string,
449+
name: string,
450+
project?: string,
451+
options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string },
452+
): Promise<ProviderRepository | undefined> {
453+
if (providerId === HostingIntegrationId.AzureDevOps && project != null) {
454+
const { provider, token } = await this.ensureProviderTokenAndFunction(
455+
providerId,
456+
'getRepoOfProjectFn',
457+
options?.accessToken,
458+
);
459+
460+
try {
461+
const result = await provider['getRepoOfProjectFn']?.(
462+
{ namespace: owner, name: name, project: project },
463+
{ token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl },
464+
);
465+
return result?.data;
466+
} catch (e) {
467+
return this.handleProviderError<ProviderRepository>(providerId, token, e);
468+
}
469+
} else {
470+
const { provider, token } = await this.ensureProviderTokenAndFunction(
471+
providerId,
472+
'getRepoFn',
473+
options?.accessToken,
474+
);
475+
476+
try {
477+
const result = await provider['getRepoFn']?.(
478+
{ namespace: owner, name: name, project: project },
479+
{ token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl },
480+
);
481+
return result?.data;
482+
} catch (e) {
483+
return this.handleProviderError<ProviderRepository>(providerId, token, e);
484+
}
485+
}
486+
}
487+
445488
async getCurrentUser(
446489
providerId: IntegrationId,
447490
options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string },

0 commit comments

Comments
 (0)