Skip to content

Commit 2cef8f4

Browse files
sergeibbbaxosoft-ramint
authored andcommitted
Generates create AzureDevOps PR URLs and retrieves repoId for cross-forks
(#4142, #4143)
1 parent 1b7921c commit 2cef8f4

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
@@ -42,7 +42,7 @@ const builtInProviders: RemoteProviders = [
4242
{
4343
custom: false,
4444
matcher: /\bdev\.azure\.com$/i,
45-
creator: (_container: Container, domain: string, path: string) => new AzureDevOpsRemote(domain, path),
45+
creator: (container: Container, domain: string, path: string) => new AzureDevOpsRemote(container, domain, path),
4646
},
4747
{
4848
custom: true,
@@ -57,8 +57,8 @@ const builtInProviders: RemoteProviders = [
5757
{
5858
custom: false,
5959
matcher: /\bvisualstudio\.com$/i,
60-
creator: (_container: Container, domain: string, path: string) =>
61-
new AzureDevOpsRemote(domain, path, undefined, undefined, true),
60+
creator: (container: Container, domain: string, path: string) =>
61+
new AzureDevOpsRemote(container, domain, path, undefined, undefined, true),
6262
},
6363
{
6464
custom: false,
@@ -145,8 +145,8 @@ export function loadRemoteProviders(
145145
function getCustomProviderCreator(cfg: RemotesConfig) {
146146
switch (cfg.type) {
147147
case 'AzureDevOps':
148-
return (_container: Container, domain: string, path: string) =>
149-
new AzureDevOpsRemote(domain, path, cfg.protocol, cfg.name, true);
148+
return (container: Container, domain: string, path: string) =>
149+
new AzureDevOpsRemote(container, domain, path, cfg.protocol, cfg.name, true);
150150
case 'Bitbucket':
151151
return (_container: Container, domain: string, path: string) =>
152152
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
@@ -234,6 +234,15 @@ export interface PageInfo {
234234
nextPage?: number | null;
235235
}
236236

237+
export type GetRepoFn = (
238+
input: ProviderRepoInput,
239+
options?: EnterpriseOptions,
240+
) => Promise<{ data: ProviderRepository }>;
241+
export type GetRepoOfProjectFn = (
242+
input: ProviderRepoInput & { project: string },
243+
options?: EnterpriseOptions,
244+
) => Promise<{ data: ProviderRepository }>;
245+
237246
export type GetPullRequestsForReposFn = (
238247
input: (GetPullRequestsForReposInput | GetPullRequestsForRepoIdsInput) & PagingInput,
239248
options?: EnterpriseOptions,
@@ -382,6 +391,8 @@ export type GetIssuesForResourceForCurrentUserFn = (
382391

383392
export interface ProviderInfo extends ProviderMetadata {
384393
provider: GitHub | GitLab | Bitbucket | BitbucketServer | Jira | Trello | AzureDevOps;
394+
getRepoFn?: GetRepoFn;
395+
getRepoOfProjectFn?: GetRepoOfProjectFn;
385396
getPullRequestsForReposFn?: GetPullRequestsForReposFn;
386397
getPullRequestsForRepoFn?: GetPullRequestsForRepoFn;
387398
getPullRequestsForUserFn?: GetPullRequestsForUserFn;

src/plus/integrations/providers/providersApi.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export class ProvidersApi {
238238
[HostingIntegrationId.AzureDevOps]: {
239239
...providersMetadata[HostingIntegrationId.AzureDevOps],
240240
provider: providerApis.azureDevOps,
241+
getRepoOfProjectFn: providerApis.azureDevOps.getRepo.bind(providerApis.azureDevOps),
241242
getCurrentUserFn: providerApis.azureDevOps.getCurrentUser.bind(
242243
providerApis.azureDevOps,
243244
) as GetCurrentUserFn,
@@ -465,6 +466,48 @@ export class ProvidersApi {
465466
}
466467
}
467468

469+
async getRepo(
470+
providerId: IntegrationId,
471+
owner: string,
472+
name: string,
473+
project?: string,
474+
options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string },
475+
): Promise<ProviderRepository | undefined> {
476+
if (providerId === HostingIntegrationId.AzureDevOps && project != null) {
477+
const { provider, token } = await this.ensureProviderTokenAndFunction(
478+
providerId,
479+
'getRepoOfProjectFn',
480+
options?.accessToken,
481+
);
482+
483+
try {
484+
const result = await provider['getRepoOfProjectFn']?.(
485+
{ namespace: owner, name: name, project: project },
486+
{ token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl },
487+
);
488+
return result?.data;
489+
} catch (e) {
490+
return this.handleProviderError<ProviderRepository>(providerId, token, e);
491+
}
492+
} else {
493+
const { provider, token } = await this.ensureProviderTokenAndFunction(
494+
providerId,
495+
'getRepoFn',
496+
options?.accessToken,
497+
);
498+
499+
try {
500+
const result = await provider['getRepoFn']?.(
501+
{ namespace: owner, name: name, project: project },
502+
{ token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl },
503+
);
504+
return result?.data;
505+
} catch (e) {
506+
return this.handleProviderError<ProviderRepository>(providerId, token, e);
507+
}
508+
}
509+
}
510+
468511
async getCurrentUser(
469512
providerId: IntegrationId,
470513
options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string },

0 commit comments

Comments
 (0)