@gitkraken/core-gitlens 0.5.115 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/git/models/issue.d.ts +14 -1
  3. package/dist/git/models/issue.d.ts.map +1 -1
  4. package/dist/git/models/issue.js +5 -1
  5. package/dist/git/models/issue.js.map +1 -1
  6. package/dist/git/models/pullRequest.d.ts +8 -2
  7. package/dist/git/models/pullRequest.d.ts.map +1 -1
  8. package/dist/git/remotes/azure-devops.d.ts +11 -1
  9. package/dist/git/remotes/azure-devops.d.ts.map +1 -1
  10. package/dist/git/remotes/azure-devops.js +47 -12
  11. package/dist/git/remotes/azure-devops.js.map +1 -1
  12. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  13. package/dist/git/utils/issue.utils.js +9 -0
  14. package/dist/git/utils/issue.utils.js.map +1 -1
  15. package/dist/plus/git-github/api/github.d.ts +3 -2
  16. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  17. package/dist/plus/git-github/api/github.js.map +1 -1
  18. package/dist/plus/integrations/manager.d.ts +43 -6
  19. package/dist/plus/integrations/manager.d.ts.map +1 -1
  20. package/dist/plus/integrations/providers/azure/azure.d.ts +13 -0
  21. package/dist/plus/integrations/providers/azure/azure.d.ts.map +1 -1
  22. package/dist/plus/integrations/providers/azure/azure.js +79 -16
  23. package/dist/plus/integrations/providers/azure/azure.js.map +1 -1
  24. package/dist/plus/integrations/providers/azure/models.d.ts +37 -10
  25. package/dist/plus/integrations/providers/azure/models.d.ts.map +1 -1
  26. package/dist/plus/integrations/providers/azure/models.js +118 -52
  27. package/dist/plus/integrations/providers/azure/models.js.map +1 -1
  28. package/dist/plus/integrations/providers/azureDevOps.d.ts +1 -0
  29. package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
  30. package/dist/plus/integrations/providers/azureDevOps.js +19 -7
  31. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  32. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  33. package/dist/plus/integrations/providers/models.js +15 -4
  34. package/dist/plus/integrations/providers/models.js.map +1 -1
  35. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  36. package/dist/plus/integrations/reads/broaden.js +26 -14
  37. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  38. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  39. package/dist/plus/integrations/reads/counts.js +39 -15
  40. package/dist/plus/integrations/reads/counts.js.map +1 -1
  41. package/dist/plus/integrations/reads/filters.d.ts +33 -11
  42. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  43. package/dist/plus/integrations/reads/filters.js +114 -25
  44. package/dist/plus/integrations/reads/filters.js.map +1 -1
  45. package/dist/plus/integrations/reads/resolveRepository.d.ts.map +1 -1
  46. package/dist/plus/integrations/reads/resolveRepository.js +2 -0
  47. package/dist/plus/integrations/reads/resolveRepository.js.map +1 -1
  48. package/dist/plus/integrations/reads/searchIssues.d.ts +1 -5
  49. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
  50. package/dist/plus/integrations/reads/searchIssues.js +12 -6
  51. package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
  52. package/dist/plus/integrations/reads/searchPullRequests.d.ts +1 -0
  53. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
  54. package/dist/plus/integrations/reads/searchPullRequests.js +8 -3
  55. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
  56. package/dist/plus/integrations/reads/warnings.d.ts +16 -0
  57. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  58. package/dist/plus/integrations/reads/warnings.js +18 -0
  59. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  60. package/docs/integrations.md +59 -2
  61. package/docs/kepler-read-api-parity.md +15 -12
  62. package/package.json +1 -1
  63. package/src/git/models/issue.ts +15 -0
  64. package/src/git/models/pullRequest.ts +6 -2
  65. package/src/git/remotes/azure-devops.ts +68 -14
  66. package/src/git/utils/issue.utils.ts +10 -0
  67. package/src/plus/git-github/api/github.ts +3 -2
  68. package/src/plus/integrations/manager.ts +43 -6
  69. package/src/plus/integrations/providers/azure/azure.ts +147 -16
  70. package/src/plus/integrations/providers/azure/models.ts +165 -63
  71. package/src/plus/integrations/providers/azureDevOps.ts +22 -13
  72. package/src/plus/integrations/providers/models.ts +22 -5
  73. package/src/plus/integrations/reads/broaden.ts +35 -16
  74. package/src/plus/integrations/reads/counts.ts +62 -15
  75. package/src/plus/integrations/reads/filters.ts +131 -29
  76. package/src/plus/integrations/reads/resolveRepository.ts +3 -0
  77. package/src/plus/integrations/reads/searchIssues.ts +23 -13
  78. package/src/plus/integrations/reads/searchPullRequests.ts +16 -2
  79. package/src/plus/integrations/reads/warnings.ts +19 -0
@@ -7,11 +7,14 @@ import type { Provider } from '../../../../git/models/remoteProvider.js';
7
7
  import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata.js';
8
8
  import { base64 } from '../../../../utils/base64.js';
9
9
  import { CancellationError, isCancellationError } from '../../../../utils/cancellation.js';
10
+ import { sha256 } from '../../../../utils/crypto.js';
10
11
  import { trace } from '../../../../utils/decorators/log.js';
11
12
  import type { Disposable } from '../../../../utils/disposable.js';
12
13
  import { Logger } from '../../../../utils/logger.js';
13
14
  import type { ScopedLogger } from '../../../../utils/logger.scoped.js';
14
15
  import { getScopedLogger } from '../../../../utils/logger.scoped.js';
16
+ import type { CacheController } from '../../../../utils/promiseCache.js';
17
+ import { PromiseCache } from '../../../../utils/promiseCache.js';
15
18
  import { maybeStopWatch } from '../../../../utils/stopwatch.js';
16
19
  import type { TokenWithInfo } from '../../authentication/models.js';
17
20
  import type { IntegrationServiceContext } from '../../context.js';
@@ -27,10 +30,13 @@ import {
27
30
  import type { ProviderApiConfig } from '../apiConfig.js';
28
31
  import { baseProviderApiConfig } from '../apiConfig.js';
29
32
  import type {
33
+ AzureForkRepositoryUrls,
30
34
  AzureGitCommit,
31
35
  AzureProjectDescriptor,
32
36
  AzurePullRequest,
33
37
  AzurePullRequestWithLinks,
38
+ AzureRepositoryReference,
39
+ AzureRepositoryUrls,
34
40
  AzureRepositoryWithMetadata,
35
41
  AzureWorkItemState,
36
42
  AzureWorkItemStateCategory,
@@ -45,8 +51,21 @@ import {
45
51
  isClosedAzurePullRequestStatus,
46
52
  isClosedAzureWorkItemStateCategory,
47
53
  normalizeAzureBranchName,
54
+ sanitizeAzureRepositoryUrl,
48
55
  } from './models.js';
49
56
 
57
+ const forkRepositoryUrlCacheTtl = 5 * 60 * 1000;
58
+
59
+ function parseAzureRepositoryDescriptor(repo: string): { projectName: string; repoName: string } {
60
+ const parts = repo.split('/');
61
+ const [projectName, segment, repoName] = parts;
62
+ if (parts.length !== 3 || segment !== '_git' || !projectName || !repoName) {
63
+ throw new Error(`Invalid Azure repository descriptor '${repo}'; expected '{project}/_git/{repoName}'.`);
64
+ }
65
+
66
+ return { projectName: projectName, repoName: repoName };
67
+ }
68
+
50
69
  class WorkItemStates {
51
70
  private readonly _categories = new Map<string, AzureWorkItemStateCategory>();
52
71
  private readonly _types = new Map<string, AzureWorkItemState[]>();
@@ -99,6 +118,10 @@ class WorkItemStates {
99
118
  export class AzureDevOpsApi implements Disposable {
100
119
  private readonly _disposable: Disposable | undefined;
101
120
  private _workItemStates: WorkItemStates = new WorkItemStates();
121
+ private readonly _forkRepositoryUrls = new PromiseCache<string, AzureForkRepositoryUrls | undefined>({
122
+ capacity: 100,
123
+ createTTL: forkRepositoryUrlCacheTtl,
124
+ });
102
125
 
103
126
  constructor(private readonly config: ProviderApiConfig) {
104
127
  this._disposable = config.onConfigChanged?.(() => this.resetCaches());
@@ -110,6 +133,7 @@ export class AzureDevOpsApi implements Disposable {
110
133
 
111
134
  private resetCaches(): void {
112
135
  this._workItemStates.clear();
136
+ this._forkRepositoryUrls.clear();
113
137
  }
114
138
 
115
139
  @trace({
@@ -132,7 +156,7 @@ export class AzureDevOpsApi implements Disposable {
132
156
  },
133
157
  ): Promise<PullRequest | undefined> {
134
158
  const scope = getScopedLogger();
135
- const [projectName, _, repoName] = repo.split('/');
159
+ const { projectName, repoName } = parseAzureRepositoryDescriptor(repo);
136
160
 
137
161
  try {
138
162
  const prResult = await this.request<{ value: AzurePullRequest[] }>(
@@ -167,7 +191,7 @@ export class AzureDevOpsApi implements Disposable {
167
191
  const pr = sortedPRs?.[0];
168
192
  if (pr == null) return undefined;
169
193
 
170
- return fromAzurePullRequest(pr, provider, owner);
194
+ return await this.toPullRequest(pr, provider, token, owner, options.baseUrl, scope);
171
195
  } catch (ex) {
172
196
  scope?.error(ex);
173
197
  return undefined;
@@ -197,7 +221,7 @@ export class AzureDevOpsApi implements Disposable {
197
221
  cancellation?: AbortSignal,
198
222
  ): Promise<PullRequest | undefined> {
199
223
  const scope = getScopedLogger();
200
- const [projectName, _, repoName] = repo.split('/');
224
+ const { projectName, repoName } = parseAzureRepositoryDescriptor(repo);
201
225
  try {
202
226
  const prResult = await this.request<{ results: Record<string, AzurePullRequest[]>[] }>(
203
227
  provider,
@@ -225,15 +249,15 @@ export class AzureDevOpsApi implements Disposable {
225
249
  const pullRequest = await this.request<AzurePullRequestWithLinks>(
226
250
  provider,
227
251
  token,
228
- undefined,
229
- pr.url,
252
+ baseUrl,
253
+ `${owner}/${encodeURIComponent(pr.repository.project.id)}/_apis/git/repositories/${encodeURIComponent(pr.repository.id)}/pullRequests/${encodeURIComponent(pr.pullRequestId.toString())}`,
230
254
  { method: 'GET' },
231
255
  scope,
232
256
  cancellation,
233
257
  );
234
258
  if (pullRequest == null) return undefined;
235
259
 
236
- return fromAzurePullRequest(pullRequest, provider, owner);
260
+ return await this.toPullRequest(pullRequest, provider, token, owner, baseUrl, scope, cancellation);
237
261
  } catch (ex) {
238
262
  scope?.error(ex);
239
263
  return undefined;
@@ -261,7 +285,7 @@ export class AzureDevOpsApi implements Disposable {
261
285
  },
262
286
  ): Promise<IssueOrPullRequest | undefined> {
263
287
  const scope = getScopedLogger();
264
- const [projectName, _, repoName] = repo.split('/');
288
+ const { projectName, repoName } = parseAzureRepositoryDescriptor(repo);
265
289
 
266
290
  if (options?.type === undefined || options?.type === 'issue') {
267
291
  try {
@@ -335,7 +359,7 @@ export class AzureDevOpsApi implements Disposable {
335
359
  state: azurePullRequestStatusToState(prResult.status),
336
360
  closed: isClosedAzurePullRequestStatus(prResult.status),
337
361
  title: prResult.title,
338
- url: getAzurePullRequestWebUrl(prResult),
362
+ url: getAzurePullRequestWebUrl(prResult, options.baseUrl, owner),
339
363
  };
340
364
  }
341
365
 
@@ -428,7 +452,7 @@ export class AzureDevOpsApi implements Disposable {
428
452
  },
429
453
  ): Promise<UnidentifiedAuthor | undefined> {
430
454
  const scope = getScopedLogger();
431
- const [projectName, _, repoName] = repo.split('/');
455
+ const { projectName, repoName } = parseAzureRepositoryDescriptor(repo);
432
456
 
433
457
  try {
434
458
  // Try to get the Work item (wit) first with specific fields
@@ -687,12 +711,7 @@ export class AzureDevOpsApi implements Disposable {
687
711
  scope: ScopedLogger | undefined,
688
712
  cancellation?: AbortSignal,
689
713
  ): Promise<AzureRepositoryWithMetadata | undefined> {
690
- const parts = repo.split('/');
691
- const [projectName, segment, repoName] = parts;
692
- // The descriptor must be exactly `"{project}/_git/{repoName}"`; bail before issuing a bogus request.
693
- if (parts.length !== 3 || segment !== '_git' || !projectName || !repoName) {
694
- throw new Error(`Invalid Azure repository descriptor '${repo}'; expected '{project}/_git/{repoName}'.`);
695
- }
714
+ const { projectName, repoName } = parseAzureRepositoryDescriptor(repo);
696
715
  return this.request<AzureRepositoryWithMetadata>(
697
716
  provider,
698
717
  token,
@@ -704,6 +723,118 @@ export class AzureDevOpsApi implements Disposable {
704
723
  );
705
724
  }
706
725
 
726
+ private async toPullRequest(
727
+ pr: AzurePullRequest,
728
+ provider: Provider,
729
+ token: TokenWithInfo,
730
+ owner: string,
731
+ baseUrl: string,
732
+ scope: ScopedLogger | undefined,
733
+ cancellation?: AbortSignal,
734
+ ): Promise<PullRequest> {
735
+ const forkRepositoryUrls = await this.getForkRepositoryUrls(
736
+ provider,
737
+ token,
738
+ owner,
739
+ baseUrl,
740
+ pr.forkSource?.repository,
741
+ scope,
742
+ cancellation,
743
+ );
744
+ return fromAzurePullRequest(pr, provider, owner, baseUrl, forkRepositoryUrls);
745
+ }
746
+
747
+ /**
748
+ * Resolves the web and HTTPS clone URLs of the fork a cross-repository pull request comes from. Every other
749
+ * repository URL the model reports is rebuilt from the pull request payload; only a fork reference lacks the
750
+ * project to do that, so only a fork costs a request.
751
+ *
752
+ * Best-effort by contract: a fork in a project the token cannot read, a deleted fork, or a throttled request
753
+ * leaves the head ref without a URL. It must never cost the pull request itself, which is what makes swallowing
754
+ * the failure here the right call rather than a shortcut.
755
+ */
756
+ private async getForkRepositoryUrls(
757
+ provider: Provider,
758
+ token: TokenWithInfo,
759
+ owner: string,
760
+ baseUrl: string,
761
+ repository: AzureRepositoryReference | undefined,
762
+ scope: ScopedLogger | undefined,
763
+ cancellation?: AbortSignal,
764
+ ): Promise<AzureForkRepositoryUrls | undefined> {
765
+ if (repository == null) return undefined;
766
+
767
+ let tokenHash: string;
768
+ try {
769
+ tokenHash = await sha256(token.accessToken);
770
+ } catch {
771
+ return undefined;
772
+ }
773
+
774
+ const cacheKey = JSON.stringify([tokenHash, baseUrl, owner, repository.id]);
775
+ // The request is shared, so the factory is handed the cache's aggregate signal rather than this caller's: it
776
+ // aborts only once every caller waiting on the entry has cancelled.
777
+ return this._forkRepositoryUrls.getOrCreate(
778
+ cacheKey,
779
+ (cacheable, aggregate) =>
780
+ this.fetchForkRepositoryUrls(
781
+ provider,
782
+ token,
783
+ owner,
784
+ baseUrl,
785
+ repository.id,
786
+ cacheable,
787
+ scope,
788
+ aggregate,
789
+ ),
790
+ { cancellation: cancellation },
791
+ );
792
+ }
793
+
794
+ private async fetchForkRepositoryUrls(
795
+ provider: Provider,
796
+ token: TokenWithInfo,
797
+ owner: string,
798
+ baseUrl: string,
799
+ repositoryId: string,
800
+ cacheable: CacheController,
801
+ scope: ScopedLogger | undefined,
802
+ cancellation?: AbortSignal,
803
+ ): Promise<AzureForkRepositoryUrls | undefined> {
804
+ try {
805
+ const response = await this.request<AzureRepositoryUrls>(
806
+ provider,
807
+ token,
808
+ baseUrl,
809
+ `${owner}/_apis/git/repositories/${encodeURIComponent(repositoryId)}?api-version=4.1`,
810
+ { method: 'GET' },
811
+ scope,
812
+ cancellation,
813
+ );
814
+ const webUrl = sanitizeAzureRepositoryUrl(response?.webUrl, baseUrl, owner);
815
+ const cloneHttps = sanitizeAzureRepositoryUrl(response?.remoteUrl, baseUrl, owner);
816
+
817
+ // The clone URL stands in for the web URL when the response omits it — an older `api-version` promises
818
+ // neither field, and naming the fork by the URL git would use beats naming it not at all.
819
+ const url = webUrl ?? cloneHttps;
820
+ if (url == null) {
821
+ cacheable.invalidate();
822
+ return undefined;
823
+ }
824
+
825
+ return { url: url, cloneHttps: cloneHttps };
826
+ } catch (ex) {
827
+ // Missing or inaccessible forks are cached briefly; transient failures retry on the next read.
828
+ if (!(ex instanceof RequestNotFoundError) && !(ex instanceof AuthenticationError)) {
829
+ cacheable.invalidate();
830
+ }
831
+
832
+ const status = ex instanceof ProviderFetchError ? ` (${ex.status})` : '';
833
+ scope?.warn(`Unable to resolve the fork repository URLs${status}`);
834
+ return undefined;
835
+ }
836
+ }
837
+
707
838
  private async request<T>(
708
839
  provider: Provider,
709
840
  token: TokenWithInfo,
@@ -718,7 +849,7 @@ export class AzureDevOpsApi implements Disposable {
718
849
 
719
850
  let rsp: Response;
720
851
  try {
721
- const sw = maybeStopWatch(`[AZURE] ${options?.method ?? 'GET'} ${url}`, { log: { onlyExit: true } });
852
+ const sw = maybeStopWatch(`[AZURE] ${options?.method ?? 'GET'} request`, { log: { onlyExit: true } });
722
853
 
723
854
  try {
724
855
  if (cancellation?.aborted) throw new CancellationError();
@@ -17,6 +17,7 @@ export interface AzureRepositoryDescriptor extends ResourceDescriptor {
17
17
  owner: string;
18
18
  name: string;
19
19
  project?: string;
20
+ virtualDirectory?: string;
20
21
  }
21
22
 
22
23
  export interface AzureOrganizationDescriptor extends ResourceDescriptor {
@@ -190,10 +191,14 @@ export interface AzureProject {
190
191
  lastUpdateTime: string;
191
192
  }
192
193
 
193
- export interface AzureRepository {
194
+ export interface AzureRepositoryReference {
194
195
  id: string;
195
196
  name: string;
196
197
  url: string;
198
+ remoteUrl?: string;
199
+ }
200
+
201
+ export interface AzureRepository extends AzureRepositoryReference {
197
202
  project: AzureProject;
198
203
  size: number;
199
204
  remoteUrl: string;
@@ -203,6 +208,13 @@ export interface AzureRepository {
203
208
  isInMaintenance: boolean;
204
209
  }
205
210
 
211
+ export interface AzurePullRequestRepository extends AzureRepositoryReference {
212
+ project: Pick<AzureProject, 'id' | 'name'>;
213
+ }
214
+
215
+ /** The URL fields of a repository response — all the fork lookup reads, and all an older `api-version` promises. */
216
+ export type AzureRepositoryUrls = Partial<Pick<AzureRepository, 'webUrl' | 'remoteUrl'>>;
217
+
206
218
  /** The `GET .../_apis/git/repositories/{repositoryId or name}` response, adding fork/default-branch fields to {@link AzureRepository}. */
207
219
  export interface AzureRepositoryWithMetadata extends AzureRepository {
208
220
  /** Fully-qualified ref, e.g. `refs/heads/main`. */
@@ -290,7 +302,7 @@ export interface AzureGitForkRef {
290
302
  name: string;
291
303
  objectId: string;
292
304
  peeledObjectId: string;
293
- repository: AzureRepository;
305
+ repository: AzureRepositoryReference;
294
306
  statuses: AzureGitStatus[];
295
307
  url: string;
296
308
  }
@@ -317,7 +329,7 @@ export type AzurePullRequestAsyncStatus =
317
329
  | 'succeeded';
318
330
 
319
331
  export interface AzurePullRequest {
320
- repository: AzureRepository;
332
+ repository: AzurePullRequestRepository;
321
333
  pullRequestId: number;
322
334
  codeReviewId: number;
323
335
  status: AzurePullRequestStatus;
@@ -368,70 +380,128 @@ export interface AzurePullRequestWithLinks extends AzurePullRequest {
368
380
  workItemRefs?: AzureResourceRef[];
369
381
  }
370
382
 
371
- export function getVSTSOwner(url: URL): string {
372
- return url.hostname.split('.')[0];
373
- }
374
383
  export function getAzureDevOpsOwner(url: URL): string {
375
384
  return url.pathname.split('/')[1];
376
385
  }
377
- export function getAzureOwner(url: URL): string {
378
- const isVSTS = url.hostname.endsWith(vstsHostnameSuffix);
379
- return isVSTS ? getVSTSOwner(url) : getAzureDevOpsOwner(url);
380
- }
381
386
  export function isVsts(domain: string): boolean {
382
387
  return domain.endsWith(vstsHostnameSuffix);
383
388
  }
384
389
 
385
- export function getAzureRepo(pr: AzurePullRequest): string {
386
- return `${pr.repository.project.name}/_git/${pr.repository.name}`;
387
- }
388
-
389
- // Example: https://bbbchiv.visualstudio.com/MyFirstProject/_git/test
390
- const azureProjectRepoRegex = /([^/]+)\/_git\/([^/]+)/;
391
- function parseVstsHttpsUrl(url: URL): [owner: string, project: string, repo: string] {
392
- const owner = getVSTSOwner(url);
393
- const match = azureProjectRepoRegex.exec(url.pathname);
394
- if (match == null) {
395
- throw new Error(`Invalid VSTS URL: ${url.toString()}`);
396
- }
397
-
398
- const [, project, repo] = match;
399
- return [owner, project, repo];
400
- }
401
-
402
- // Example https://bbbchiv2@dev.azure.com/bbbchiv2/MyFirstProject/_git/test
403
- const azureHttpsUrlRegex = /([^/]+)\/([^/]+)\/_git\/([^/]+)/;
404
- function parseAzureNewStyleUrl(url: URL): [owner: string, project: string, repo: string] {
405
- const match = azureHttpsUrlRegex.exec(url.pathname);
406
- if (match == null) {
407
- throw new Error(`Invalid Azure URL: ${url.toString()}`);
390
+ /**
391
+ * `baseUrl` and `owner` are the authoritative prefix used for the API request. The payload URL cannot supply that
392
+ * prefix safely because it may address the repository by id or name an untrusted host.
393
+ */
394
+ function getAzureRepositoryWebUrl(baseUrl: string, owner: string, projectName: string, repoName: string): string {
395
+ const repoPath = `${encodeURIComponent(owner)}/${encodeURIComponent(projectName)}/_git/${encodeURIComponent(repoName)}`;
396
+ return `${baseUrl.replace(/\/+$/, '')}/${repoPath}`;
397
+ }
398
+
399
+ export function getAzurePullRequestWebUrl(pr: AzurePullRequest, baseUrl: string, owner: string): string {
400
+ const repoUrl = getAzureRepositoryWebUrl(baseUrl, owner, pr.repository.project.name, pr.repository.name);
401
+ return `${repoUrl}/pullrequest/${pr.pullRequestId}`;
402
+ }
403
+
404
+ /**
405
+ * Whether `url` and `expected` are the two cloud spellings of one organization: a cloud organization answers on both
406
+ * `dev.azure.com/{owner}` and the legacy `{owner}.visualstudio.com`, so those name the same host. Anything else is
407
+ * somewhere we didn't ask.
408
+ */
409
+ function isSameAzureCloudOrganization(url: URL, expected: URL, expectedOwner: string): boolean {
410
+ if (url.protocol !== expected.protocol) return false;
411
+
412
+ const owner = expectedOwner.toLowerCase();
413
+ if (expected.hostname !== 'dev.azure.com' && !isVsts(expected.hostname)) return false;
414
+
415
+ if (url.hostname === 'dev.azure.com') return getAzureDevOpsOwner(url).toLowerCase() === owner;
416
+
417
+ return url.hostname === `${owner}${vstsHostnameSuffix}`;
418
+ }
419
+
420
+ /**
421
+ * Whether `url` sits under the collection the integration is configured to talk to — `{expectedUrl}/{expectedOwner}`.
422
+ * A matching origin says nothing on its own: Azure organizations and self-hosted collections are path segments.
423
+ */
424
+ function isUnderAzureCollection(url: URL, expected: URL, expectedOwner: string): boolean {
425
+ const base = expected.pathname.replace(/\/+$/, '');
426
+ const prefix = `${base}/${encodeURIComponent(expectedOwner)}/`.toLowerCase();
427
+ return `${url.pathname}/`.toLowerCase().startsWith(prefix);
428
+ }
429
+
430
+ /**
431
+ * Restricts a provider-supplied repository URL to the integration's collection and removes credentials, query, and
432
+ * fragment before a consumer can use it as a git remote. Takes the value as the payloads carry it — every one of
433
+ * these URLs is optional — so a caller never has to spell the absent case itself.
434
+ */
435
+ export function sanitizeAzureRepositoryUrl(
436
+ value: string | undefined,
437
+ expectedUrl: string,
438
+ expectedOwner: string,
439
+ ): string | undefined {
440
+ if (value == null) return undefined;
441
+
442
+ let expected: URL;
443
+ let url: URL;
444
+ try {
445
+ expected = new URL(expectedUrl);
446
+ url = new URL(value);
447
+ } catch {
448
+ return undefined;
408
449
  }
409
450
 
410
- const [, owner, project, repo] = match;
411
- return [owner, project, repo];
412
- }
451
+ if (expected.protocol !== 'https:' && expected.protocol !== 'http:') return undefined;
452
+ if (!expectedOwner) return undefined;
413
453
 
414
- export function parseAzureHttpsUrl(url: string): [owner: string, project: string, repo: string];
415
- export function parseAzureHttpsUrl(urlObj: URL): [owner: string, project: string, repo: string];
416
- export function parseAzureHttpsUrl(arg: URL | string): [owner: string, project: string, repo: string] {
417
- const url = typeof arg === 'string' ? new URL(arg) : arg;
418
- if (url.hostname.endsWith(vstsHostnameSuffix)) {
419
- return parseVstsHttpsUrl(url);
454
+ if (url.origin === expected.origin) {
455
+ if (!isUnderAzureCollection(url, expected, expectedOwner)) return undefined;
456
+ } else if (!isSameAzureCloudOrganization(url, expected, expectedOwner)) {
457
+ return undefined;
420
458
  }
421
- return parseAzureNewStyleUrl(url);
422
- }
423
459
 
424
- export function getAzurePullRequestWebUrl(pr: AzurePullRequest): string {
425
- const url = new URL(pr.url);
426
- const baseUrl = new URL(url.origin).toString();
427
- const repoPath = getAzureRepo(pr);
428
- const isVSTS = url.hostname.endsWith(vstsHostnameSuffix);
429
- if (isVSTS) {
430
- return `${baseUrl}/${repoPath}/pullrequest/${pr.pullRequestId}`;
460
+ url.username = '';
461
+ url.password = '';
462
+ url.search = '';
463
+ url.hash = '';
464
+ return url.toString();
465
+ }
466
+
467
+ /**
468
+ * The clone URL the payload carries for a repository, restricted to the integration's collection and then
469
+ * cross-checked against the repository that payload names.
470
+ *
471
+ * The collection check alone is not enough for a URL that is READ rather than built. Every other repository URL the
472
+ * model reports is built from the authoritative prefix, so the payload cannot move it; this one cannot be built,
473
+ * because Azure spells a repository for git the same way it spells it for the web only by convention. A URL under
474
+ * the right collection may still name a DIFFERENT repository in it, which would leave `cloneHttps` describing one
475
+ * repository while `url` describes another.
476
+ *
477
+ * Azure ends a clone URL with `_git/{repo}` on every host style — including the short `{owner}/_git/{repo}` form it
478
+ * uses when a repository carries its project's name, and the legacy `{owner}.visualstudio.com` spelling that has no
479
+ * organization segment at all — so the repository name is the one part comparable across all of them. A same-named
480
+ * repository in another project of the same organization is the single substitution this cannot catch.
481
+ */
482
+ function getAzureRepositoryCloneUrl(
483
+ repository: AzureRepositoryReference,
484
+ baseUrl: string,
485
+ owner: string,
486
+ ): string | undefined {
487
+ const sanitized = sanitizeAzureRepositoryUrl(repository.remoteUrl, baseUrl, owner);
488
+ if (sanitized == null) return undefined;
489
+
490
+ // Safe to parse: `sanitizeAzureRepositoryUrl` returns what it already parsed.
491
+ const segments = new URL(sanitized).pathname.split('/').filter(s => s.length > 0);
492
+ const name = segments.pop();
493
+ if (name == null || segments.pop() !== '_git') return undefined;
494
+
495
+ let decoded: string;
496
+ try {
497
+ decoded = decodeURIComponent(name);
498
+ } catch {
499
+ return undefined;
431
500
  }
432
501
 
433
- const owner = getAzureDevOpsOwner(url);
434
- return `${baseUrl}/${owner}/${repoPath}/pullrequest/${pr.pullRequestId}`;
502
+ // Azure resolves project and repository names case-insensitively, so comparing them any other way would refuse a
503
+ // URL the provider considers the same one.
504
+ return decoded.toLowerCase() === repository.name.toLowerCase() ? sanitized : undefined;
435
505
  }
436
506
 
437
507
  export function fromAzurePullRequestMergeStatusToMergeableState(
@@ -527,17 +597,47 @@ function fromAzureUserToMember(user: AzureUser, _type: 'issue' | 'pullRequest'):
527
597
  };
528
598
  }
529
599
 
530
- export function fromAzurePullRequest(pr: AzurePullRequest, provider: Provider, orgName: string): PullRequest {
531
- const url = new URL(pr.url);
600
+ /**
601
+ * The URLs of a cross-repository pull request's fork, resolved by a lookup because the pull request payload embeds
602
+ * only an abbreviated reference to it: `url` is the URL the head ref reports — the fork's web URL, or its clone URL
603
+ * when the response carries no web URL — and `cloneHttps` its HTTPS clone URL.
604
+ *
605
+ * A fork that resolves neither is reported as no fork at all rather than as an object with nothing in it, so `url`
606
+ * is always present here; `cloneHttps` alone is best-effort, and is absent when the response omits it or its URL
607
+ * sits outside the integration's collection.
608
+ */
609
+ export interface AzureForkRepositoryUrls {
610
+ url: string;
611
+ cloneHttps: string | undefined;
612
+ }
613
+
614
+ /**
615
+ * `baseUrl` and `owner` are the prefix the pull request was read through; every URL the model reports is built from
616
+ * them. The required `forkRepositoryUrls` argument makes each call site resolve the best-effort fork URLs explicitly.
617
+ */
618
+ export function fromAzurePullRequest(
619
+ pr: AzurePullRequest,
620
+ provider: Provider,
621
+ owner: string,
622
+ baseUrl: string,
623
+ forkRepositoryUrls: AzureForkRepositoryUrls | undefined,
624
+ ): PullRequest {
625
+ const baseRepositoryUrl = getAzureRepositoryWebUrl(baseUrl, owner, pr.repository.project.name, pr.repository.name);
626
+ const baseCloneHttps = getAzureRepositoryCloneUrl(pr.repository, baseUrl, owner);
627
+
628
+ const forkRepository = pr.forkSource?.repository;
629
+ const headRepositoryUrl = forkRepository == null ? baseRepositoryUrl : forkRepositoryUrls?.url;
630
+ const headCloneHttps = forkRepository == null ? baseCloneHttps : forkRepositoryUrls?.cloneHttps;
631
+
532
632
  return new PullRequest(
533
633
  provider,
534
634
  fromAzureUserToMember(pr.createdBy, 'pullRequest'),
535
635
  pr.pullRequestId.toString(),
536
636
  pr.pullRequestId.toString(),
537
637
  pr.title,
538
- getAzurePullRequestWebUrl(pr),
638
+ getAzurePullRequestWebUrl(pr, baseUrl, owner),
539
639
  {
540
- owner: getAzureOwner(url),
640
+ owner: owner,
541
641
  repo: pr.repository.name,
542
642
  id: pr.repository.id,
543
643
  // TODO: Remove this assumption once actual access level is available
@@ -555,17 +655,19 @@ export function fromAzurePullRequest(pr: AzurePullRequest, provider: Provider, o
555
655
  branch: pr.targetRefName ? normalizeAzureBranchName(pr.targetRefName) : '',
556
656
  sha: pr.lastMergeTargetCommit?.commitId ?? '',
557
657
  repo: pr.repository.name,
558
- owner: getAzureOwner(url),
658
+ owner: owner,
559
659
  exists: pr.targetRefName != null,
560
- url: pr.repository.webUrl,
660
+ url: baseRepositoryUrl,
661
+ cloneHttps: baseCloneHttps,
561
662
  },
562
663
  head: {
563
664
  branch: pr.sourceRefName ? normalizeAzureBranchName(pr.sourceRefName) : '',
564
665
  sha: pr.lastMergeSourceCommit?.commitId ?? '',
565
- repo: pr.forkSource?.repository != null ? pr.forkSource.repository.name : pr.repository.name,
566
- owner: getAzureOwner(url),
666
+ repo: forkRepository?.name ?? pr.repository.name,
667
+ owner: owner,
567
668
  exists: pr.sourceRefName != null,
568
- url: pr.forkSource?.repository != null ? pr.forkSource.repository.webUrl : pr.repository.webUrl,
669
+ url: headRepositoryUrl,
670
+ cloneHttps: headCloneHttps,
569
671
  },
570
672
  isCrossRepository: pr.forkSource != null,
571
673
  },
@@ -583,7 +685,7 @@ export function fromAzurePullRequest(pr: AzurePullRequest, provider: Provider, o
583
685
  id: pr.repository?.project?.id,
584
686
  name: pr.repository.project.name,
585
687
  resourceId: '', // TODO: This is a workaround until we can get the org id here.
586
- resourceName: orgName,
688
+ resourceName: owner,
587
689
  },
588
690
  );
589
691
  }