@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.
- package/CHANGELOG.md +30 -1
- package/dist/git/models/issue.d.ts +14 -1
- package/dist/git/models/issue.d.ts.map +1 -1
- package/dist/git/models/issue.js +5 -1
- package/dist/git/models/issue.js.map +1 -1
- package/dist/git/models/pullRequest.d.ts +8 -2
- package/dist/git/models/pullRequest.d.ts.map +1 -1
- package/dist/git/remotes/azure-devops.d.ts +11 -1
- package/dist/git/remotes/azure-devops.d.ts.map +1 -1
- package/dist/git/remotes/azure-devops.js +47 -12
- package/dist/git/remotes/azure-devops.js.map +1 -1
- package/dist/git/utils/issue.utils.d.ts.map +1 -1
- package/dist/git/utils/issue.utils.js +9 -0
- package/dist/git/utils/issue.utils.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +3 -2
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/integrations/manager.d.ts +43 -6
- package/dist/plus/integrations/manager.d.ts.map +1 -1
- package/dist/plus/integrations/providers/azure/azure.d.ts +13 -0
- package/dist/plus/integrations/providers/azure/azure.d.ts.map +1 -1
- package/dist/plus/integrations/providers/azure/azure.js +79 -16
- package/dist/plus/integrations/providers/azure/azure.js.map +1 -1
- package/dist/plus/integrations/providers/azure/models.d.ts +37 -10
- package/dist/plus/integrations/providers/azure/models.d.ts.map +1 -1
- package/dist/plus/integrations/providers/azure/models.js +118 -52
- package/dist/plus/integrations/providers/azure/models.js.map +1 -1
- package/dist/plus/integrations/providers/azureDevOps.d.ts +1 -0
- package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
- package/dist/plus/integrations/providers/azureDevOps.js +19 -7
- package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
- package/dist/plus/integrations/providers/models.d.ts.map +1 -1
- package/dist/plus/integrations/providers/models.js +15 -4
- package/dist/plus/integrations/providers/models.js.map +1 -1
- package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
- package/dist/plus/integrations/reads/broaden.js +26 -14
- package/dist/plus/integrations/reads/broaden.js.map +1 -1
- package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
- package/dist/plus/integrations/reads/counts.js +39 -15
- package/dist/plus/integrations/reads/counts.js.map +1 -1
- package/dist/plus/integrations/reads/filters.d.ts +33 -11
- package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
- package/dist/plus/integrations/reads/filters.js +114 -25
- package/dist/plus/integrations/reads/filters.js.map +1 -1
- package/dist/plus/integrations/reads/resolveRepository.d.ts.map +1 -1
- package/dist/plus/integrations/reads/resolveRepository.js +2 -0
- package/dist/plus/integrations/reads/resolveRepository.js.map +1 -1
- package/dist/plus/integrations/reads/searchIssues.d.ts +1 -5
- package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
- package/dist/plus/integrations/reads/searchIssues.js +12 -6
- package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
- package/dist/plus/integrations/reads/searchPullRequests.d.ts +1 -0
- package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
- package/dist/plus/integrations/reads/searchPullRequests.js +8 -3
- package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
- package/dist/plus/integrations/reads/warnings.d.ts +16 -0
- package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
- package/dist/plus/integrations/reads/warnings.js +18 -0
- package/dist/plus/integrations/reads/warnings.js.map +1 -1
- package/docs/integrations.md +59 -2
- package/docs/kepler-read-api-parity.md +15 -12
- package/package.json +1 -1
- package/src/git/models/issue.ts +15 -0
- package/src/git/models/pullRequest.ts +6 -2
- package/src/git/remotes/azure-devops.ts +68 -14
- package/src/git/utils/issue.utils.ts +10 -0
- package/src/plus/git-github/api/github.ts +3 -2
- package/src/plus/integrations/manager.ts +43 -6
- package/src/plus/integrations/providers/azure/azure.ts +147 -16
- package/src/plus/integrations/providers/azure/models.ts +165 -63
- package/src/plus/integrations/providers/azureDevOps.ts +22 -13
- package/src/plus/integrations/providers/models.ts +22 -5
- package/src/plus/integrations/reads/broaden.ts +35 -16
- package/src/plus/integrations/reads/counts.ts +62 -15
- package/src/plus/integrations/reads/filters.ts +131 -29
- package/src/plus/integrations/reads/resolveRepository.ts +3 -0
- package/src/plus/integrations/reads/searchIssues.ts +23 -13
- package/src/plus/integrations/reads/searchPullRequests.ts +16 -2
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
229
|
-
pr.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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'}
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
function
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
451
|
+
if (expected.protocol !== 'https:' && expected.protocol !== 'http:') return undefined;
|
|
452
|
+
if (!expectedOwner) return undefined;
|
|
413
453
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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:
|
|
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:
|
|
658
|
+
owner: owner,
|
|
559
659
|
exists: pr.targetRefName != null,
|
|
560
|
-
url:
|
|
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:
|
|
566
|
-
owner:
|
|
666
|
+
repo: forkRepository?.name ?? pr.repository.name,
|
|
667
|
+
owner: owner,
|
|
567
668
|
exists: pr.sourceRefName != null,
|
|
568
|
-
url:
|
|
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:
|
|
688
|
+
resourceName: owner,
|
|
587
689
|
},
|
|
588
690
|
);
|
|
589
691
|
}
|