@gitkraken/core-gitlens 0.7.0 → 0.7.1
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 +21 -2
- package/dist/git/remotes/azure-devops.js +3 -3
- package/dist/git/remotes/azure-devops.js.map +1 -1
- package/dist/git-cli/exec/git.js +1 -1
- package/dist/git-cli/providers/config.js +2 -2
- package/dist/git-cli/providers/pausedOperations.js +2 -2
- package/dist/git-cli/providers/pausedOperations.js.map +1 -1
- package/dist/git-cli/providers/staging.js +1 -1
- package/dist/ipc/discovery.js +1 -1
- package/dist/plus/agents/providers/claudeCodeTranscript.js +6 -6
- package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js +1 -1
- package/dist/plus/integrations/authentication/configuredIntegrationService.js +9 -2
- package/dist/plus/integrations/authentication/configuredIntegrationService.js.map +1 -1
- package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +1 -1
- package/dist/plus/integrations/index.d.ts +2 -2
- package/dist/plus/integrations/index.js.map +1 -1
- package/dist/plus/integrations/integrationService.d.ts +34 -0
- package/dist/plus/integrations/integrationService.js +90 -43
- package/dist/plus/integrations/integrationService.js.map +1 -1
- package/dist/plus/integrations/lite.js +5 -4
- package/dist/plus/integrations/lite.js.map +1 -1
- package/dist/plus/integrations/manager.d.ts +68 -23
- package/dist/plus/integrations/models/gitHostIntegration.d.ts +30 -14
- package/dist/plus/integrations/models/gitHostIntegration.js +59 -21
- package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
- package/dist/plus/integrations/models/integration.d.ts +3 -3
- package/dist/plus/integrations/models/integration.js +1 -1
- package/dist/plus/integrations/models/integration.js.map +1 -1
- package/dist/plus/integrations/models/issueReads.d.ts +17 -0
- package/dist/plus/integrations/models/pullRequestReads.d.ts +26 -0
- package/dist/plus/integrations/providers/azure/azure.d.ts +66 -1
- package/dist/plus/integrations/providers/azure/azure.js +192 -12
- package/dist/plus/integrations/providers/azure/azure.js.map +1 -1
- package/dist/plus/integrations/providers/azure/search.d.ts +144 -0
- package/dist/plus/integrations/providers/azure/search.js +250 -0
- package/dist/plus/integrations/providers/azure/search.js.map +1 -0
- package/dist/plus/integrations/providers/azureDevOps.d.ts +54 -35
- package/dist/plus/integrations/providers/azureDevOps.js +154 -121
- package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
- package/dist/plus/integrations/providers/azureDevOpsServer.d.ts +176 -0
- package/dist/plus/integrations/providers/azureDevOpsServer.js +560 -0
- package/dist/plus/integrations/providers/azureDevOpsServer.js.map +1 -0
- package/dist/plus/integrations/providers/bitbucket-server/discovery.d.ts +12 -0
- package/dist/plus/integrations/providers/bitbucket-server/discovery.js +128 -0
- package/dist/plus/integrations/providers/bitbucket-server/discovery.js.map +1 -0
- package/dist/plus/integrations/providers/bitbucket-server/models.d.ts +10 -1
- package/dist/plus/integrations/providers/bitbucket-server/models.js +3 -2
- package/dist/plus/integrations/providers/bitbucket-server/models.js.map +1 -1
- package/dist/plus/integrations/providers/bitbucket-server/pullRequestSearch.d.ts +58 -0
- package/dist/plus/integrations/providers/bitbucket-server/pullRequestSearch.js +485 -0
- package/dist/plus/integrations/providers/bitbucket-server/pullRequestSearch.js.map +1 -0
- package/dist/plus/integrations/providers/bitbucket-server.d.ts +52 -3
- package/dist/plus/integrations/providers/bitbucket-server.js +96 -20
- package/dist/plus/integrations/providers/bitbucket-server.js.map +1 -1
- package/dist/plus/integrations/providers/bitbucket.js +1 -1
- package/dist/plus/integrations/providers/github.d.ts +5 -5
- package/dist/plus/integrations/providers/github.js +34 -33
- package/dist/plus/integrations/providers/github.js.map +1 -1
- package/dist/plus/integrations/providers/gitlab.d.ts +5 -3
- package/dist/plus/integrations/providers/gitlab.js +31 -27
- package/dist/plus/integrations/providers/gitlab.js.map +1 -1
- package/dist/plus/integrations/providers/issueSorts.d.ts +1 -0
- package/dist/plus/integrations/providers/jira-server.d.ts +9 -0
- package/dist/plus/integrations/providers/jira-server.js +13 -2
- package/dist/plus/integrations/providers/jira-server.js.map +1 -1
- package/dist/plus/integrations/providers/jira.js +1 -1
- package/dist/plus/integrations/providers/linear.js +1 -1
- package/dist/plus/integrations/providers/models.d.ts +9 -0
- package/dist/plus/integrations/providers/models.js +86 -0
- package/dist/plus/integrations/providers/models.js.map +1 -1
- package/dist/plus/integrations/providers/providersApi.d.ts +10 -0
- package/dist/plus/integrations/providers/providersApi.js +16 -1
- package/dist/plus/integrations/providers/providersApi.js.map +1 -1
- package/dist/plus/integrations/providers/utils.d.ts +12 -1
- package/dist/plus/integrations/providers/utils.js +36 -6
- package/dist/plus/integrations/providers/utils.js.map +1 -1
- package/dist/plus/integrations/reads/broaden.js +5 -5
- package/dist/plus/integrations/reads/broaden.js.map +1 -1
- package/dist/plus/integrations/reads/counts.d.ts +18 -4
- package/dist/plus/integrations/reads/counts.js +66 -15
- package/dist/plus/integrations/reads/counts.js.map +1 -1
- package/dist/plus/integrations/reads/cursors.js +1 -1
- package/dist/plus/integrations/reads/drains.d.ts +1 -0
- package/dist/plus/integrations/reads/filters.d.ts +1 -1
- package/dist/plus/integrations/reads/filters.js +17 -12
- package/dist/plus/integrations/reads/filters.js.map +1 -1
- package/dist/plus/integrations/reads/hierarchy.js +1 -1
- package/dist/plus/integrations/reads/hierarchy.js.map +1 -1
- package/dist/plus/integrations/reads/issueTracker.js +1 -1
- package/dist/plus/integrations/reads/issues.js +1 -1
- package/dist/plus/integrations/reads/paging.js +1 -1
- package/dist/plus/integrations/reads/resolveRepository.d.ts +12 -11
- package/dist/plus/integrations/reads/resolveRepository.js +109 -18
- package/dist/plus/integrations/reads/resolveRepository.js.map +1 -1
- package/dist/plus/integrations/reads/searchIssues.js +4 -2
- package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
- package/dist/plus/integrations/reads/searchPullRequests.js +9 -3
- package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
- package/dist/plus/integrations/reads/trackerIssue.d.ts +9 -5
- package/dist/plus/integrations/reads/trackerIssue.js +15 -9
- package/dist/plus/integrations/reads/trackerIssue.js.map +1 -1
- package/dist/plus/integrations/reads/warnings.d.ts +6 -2
- package/dist/plus/integrations/reads/warnings.js +15 -19
- package/dist/plus/integrations/reads/warnings.js.map +1 -1
- package/dist/plus/integrations/utils/domain.utils.d.ts +19 -0
- package/dist/plus/integrations/utils/domain.utils.js +36 -1
- package/dist/plus/integrations/utils/domain.utils.js.map +1 -1
- package/dist/plus/integrations/utils/integration.utils.d.ts +1 -0
- package/dist/plus/integrations/utils/integration.utils.js +12 -1
- package/dist/plus/integrations/utils/integration.utils.js.map +1 -1
- package/dist/utils/env/node/exec.js +2 -2
- package/docs/integrations.md +176 -73
- package/docs/kepler-read-api-parity.md +19 -6
- package/package.json +1 -1
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { CancellationError } from "../../../utils/cancellation.js";
|
|
2
|
+
import { mapBounded } from "../../../utils/promise.js";
|
|
3
|
+
import { PromiseCache } from "../../../utils/promiseCache.js";
|
|
4
|
+
import { RequestNotFoundError } from "../../../git/errors.js";
|
|
5
|
+
import { uuid } from "../../../utils/crypto.js";
|
|
6
|
+
import "../../../git/models/pullRequest.js";
|
|
7
|
+
import { IntegrationReadUnavailableError } from "../errors.js";
|
|
8
|
+
import "../constants.js";
|
|
9
|
+
import { toTokenWithInfo } from "../authentication/models.js";
|
|
10
|
+
import { compareAzurePullRequestSearchPositions, getInvalidAzureSearchDate, isAzureWorkItemSearchFirstPage, parseAzurePullRequestSearchCursor, toAzurePullRequestSearchCursorKey, toAzurePullRequestSearchFilter, toAzurePullRequestSearchPosition, toAzureSearchPageSize } from "./azure/search.js";
|
|
11
|
+
import { fromProviderPullRequest, getProviderPullRequestIdentity, providersMetadata, toProviderPullRequestStates } from "./models.js";
|
|
12
|
+
import { AzureDevOpsIntegrationBase, getAzureRepositoryApiBaseUrl, getAzureRepositoryIdentity, sameAzureName, uniqueAzureNames } from "./azureDevOps.js";
|
|
13
|
+
//#region ../plus/integrations/src/providers/azureDevOpsServer.ts
|
|
14
|
+
/**
|
|
15
|
+
* Whether `value` can stand as one route segment. Encoding keeps `.` and `..` as they are, and a URL resolves them
|
|
16
|
+
* as directory steps, so a name of either would address a different route than the one named.
|
|
17
|
+
*/
|
|
18
|
+
function isAzureRouteSegment(value) {
|
|
19
|
+
return value.length > 0 && value !== "." && value !== "..";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Runs one scope of a count probe, answering a refusal of THAT scope (see `searchUnavailable`) as the error itself,
|
|
23
|
+
* which the facade reports and drops without failing the scopes around it. Anything else — a rejected credential,
|
|
24
|
+
* a failed request — still rejects, so the batch reports it.
|
|
25
|
+
*/
|
|
26
|
+
async function refusalAsCount(count) {
|
|
27
|
+
try {
|
|
28
|
+
return await count();
|
|
29
|
+
} catch (ex) {
|
|
30
|
+
if (ex instanceof IntegrationReadUnavailableError) return ex;
|
|
31
|
+
throw ex;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function findAzureCollection(collections, name) {
|
|
35
|
+
return collections.find((c) => sameAzureName(c.name, name) || c.id === name);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The most pull requests one facet of a filtered pull-request search drains. Azure has no server-side text, date or
|
|
39
|
+
* draft filter and no ordering, so a facet is read in full before any of them apply; past this bound the facet's
|
|
40
|
+
* unread rows could sort anywhere, and the search reports itself truncated instead of guessing.
|
|
41
|
+
*/
|
|
42
|
+
const azurePullRequestSearchFacetLimit = 1e3;
|
|
43
|
+
/**
|
|
44
|
+
* The page size a facet drain reads. The SDK's own default for both pull request reads, and the size it sizes
|
|
45
|
+
* `$skip` by, so the budget above counts what was actually read.
|
|
46
|
+
*/
|
|
47
|
+
const azurePullRequestSearchPageSize = 100;
|
|
48
|
+
/**
|
|
49
|
+
* How many scopes of one work-item count batch query at once. The facade already runs several batches concurrently,
|
|
50
|
+
* so this multiplies with it: kept small, so a large count preview can't flood a self-hosted server.
|
|
51
|
+
*/
|
|
52
|
+
const azureIssueCountConcurrency = 2;
|
|
53
|
+
/**
|
|
54
|
+
* How long a drained pull-request search keeps serving its continuation pages after the last one was read, and the
|
|
55
|
+
* most it lives however actively it is paged. A pagination that keeps reading stays on one consistent drain; one
|
|
56
|
+
* left idle, or paged for longer than that, is read again.
|
|
57
|
+
*/
|
|
58
|
+
const pullRequestSearchIdleTtl = 3e5;
|
|
59
|
+
const pullRequestSearchMaxTtl = 18e5;
|
|
60
|
+
/**
|
|
61
|
+
* How long a count reuses the drain a search of its query just read. Long enough for a count shown next to the
|
|
62
|
+
* search it previews to agree with it, short enough that a count polled next to an unapplied filter stays live: the
|
|
63
|
+
* window runs from the drain, not from the last count, so polling never extends it.
|
|
64
|
+
*/
|
|
65
|
+
const pullRequestCountReuseTtl = 6e4;
|
|
66
|
+
/**
|
|
67
|
+
* Everything that decides a pull-request search's result set and order, as the value its cursor is fingerprinted
|
|
68
|
+
* from. `pageSize` is left out on purpose: it only decides where a page ends, which the keyset cursor already
|
|
69
|
+
* carries.
|
|
70
|
+
*/
|
|
71
|
+
function toPullRequestSearchQuery(options, sort) {
|
|
72
|
+
const criteria = options.criteria;
|
|
73
|
+
return {
|
|
74
|
+
repos: options.repos?.map((r) => [
|
|
75
|
+
r.namespace,
|
|
76
|
+
r.project ?? null,
|
|
77
|
+
r.name
|
|
78
|
+
]),
|
|
79
|
+
org: options.org ?? null,
|
|
80
|
+
relationships: [...new Set(criteria?.relationships ?? [])].sort(),
|
|
81
|
+
states: [...new Set(criteria?.states ?? [])].sort(),
|
|
82
|
+
text: criteria?.text?.trim() ?? null,
|
|
83
|
+
draft: criteria?.draft ?? null,
|
|
84
|
+
updatedAfter: criteria?.updatedAfter ?? null,
|
|
85
|
+
createdAfter: criteria?.createdAfter ?? null,
|
|
86
|
+
sort
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const serverMetadata = providersMetadata["azure-devops-server"];
|
|
90
|
+
const serverAuthProvider = Object.freeze({
|
|
91
|
+
id: serverMetadata.id,
|
|
92
|
+
scopes: serverMetadata.scopes
|
|
93
|
+
});
|
|
94
|
+
var AzureDevOpsServerIntegration = class extends AzureDevOpsIntegrationBase {
|
|
95
|
+
domain;
|
|
96
|
+
authProvider = serverAuthProvider;
|
|
97
|
+
id = "azure-devops-server";
|
|
98
|
+
key;
|
|
99
|
+
name = "Azure DevOps Server";
|
|
100
|
+
constructor(ctx, authenticationService, getProvidersApi, didChangeConnection, domain) {
|
|
101
|
+
super(ctx, authenticationService, getProvidersApi, didChangeConnection);
|
|
102
|
+
this.domain = domain;
|
|
103
|
+
this.key = `${this.id}:${this.domain}`;
|
|
104
|
+
}
|
|
105
|
+
apiBaseUrlFor(session) {
|
|
106
|
+
return this.getSelfManagedApiBaseUrl(session);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The credential AND the installation it is used against: one integration instance serves every installation on
|
|
110
|
+
* its host (context paths differ, the host doesn't), and the same token connected to two of them must not reuse
|
|
111
|
+
* one installation's collections or projects on the other.
|
|
112
|
+
*/
|
|
113
|
+
discoveryKey(session) {
|
|
114
|
+
return `${session.accessToken}:${this.apiBaseUrlFor(session)}`;
|
|
115
|
+
}
|
|
116
|
+
getRepositoriesApiBaseUrl(session, repos) {
|
|
117
|
+
const owners = new Set(repos.map((r) => typeof r === "object" ? r.namespace : void 0));
|
|
118
|
+
const [owner] = owners;
|
|
119
|
+
const baseUrl = this.apiBaseUrlFor(session);
|
|
120
|
+
return owners.size === 1 && owner != null ? getAzureRepositoryApiBaseUrl(baseUrl, {
|
|
121
|
+
owner,
|
|
122
|
+
virtualDirectory: void 0
|
|
123
|
+
}) : baseUrl;
|
|
124
|
+
}
|
|
125
|
+
getApiOptions(session, doNotConvertToPat = false) {
|
|
126
|
+
const { options, ...rest } = super.getApiOptions(session, doNotConvertToPat);
|
|
127
|
+
return {
|
|
128
|
+
...rest,
|
|
129
|
+
options: {
|
|
130
|
+
...options,
|
|
131
|
+
baseUrl: this.apiBaseUrlFor(session)
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
getCollectionApiOptions(session, collection) {
|
|
136
|
+
return {
|
|
137
|
+
...super.getCollectionApiOptions(session, collection),
|
|
138
|
+
baseUrl: this.collectionApiBaseUrl(session, collection)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** The base a request below `collection` appends to: the installation, whichever the address already ends in. */
|
|
142
|
+
collectionApiBaseUrl(session, collection) {
|
|
143
|
+
return getAzureRepositoryApiBaseUrl(this.apiBaseUrlFor(session), {
|
|
144
|
+
owner: collection,
|
|
145
|
+
virtualDirectory: void 0
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Only the server level lists the collections: below one, Azure DevOps Server answers `projectCollections` with a
|
|
150
|
+
* 404 page. On that 404 an address that names a collection reads the collection's own connection data, whose
|
|
151
|
+
* `webApplicationRelativeDirectory` spells it, and reports it as the one collection visible there.
|
|
152
|
+
*/
|
|
153
|
+
async requestResourcesForUser(session, userId) {
|
|
154
|
+
try {
|
|
155
|
+
return await super.requestResourcesForUser(session, userId);
|
|
156
|
+
} catch (ex) {
|
|
157
|
+
if (!(ex instanceof RequestNotFoundError)) throw ex;
|
|
158
|
+
const addressed = await (await this.authenticationService.apis.azure)?.getAddressedCollection(this, toTokenWithInfo(this.id, session), this.apiBaseUrlFor(session));
|
|
159
|
+
if (addressed == null) throw ex;
|
|
160
|
+
return [addressed];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The filtered work-item search: one WIQL query per page over ONE collection, then one detail read for the
|
|
165
|
+
* page's slice. See {@link resolveWorkItemSearchScope} for why a search never spans several collections.
|
|
166
|
+
*/
|
|
167
|
+
async searchProviderIssuesPage(session, options, cancellation) {
|
|
168
|
+
const azure = await this.authenticationService.apis.azure;
|
|
169
|
+
if (azure == null) return void 0;
|
|
170
|
+
this.assertSearchDates(options.criteria);
|
|
171
|
+
const scope = await this.resolveWorkItemSearchScope(session, options.repos, options.org, isAzureWorkItemSearchFirstPage(options.cursor));
|
|
172
|
+
return azure.searchWorkItemsPage(this, toTokenWithInfo(this.id, session), scope.collection, {
|
|
173
|
+
baseUrl: this.collectionApiBaseUrl(session, scope.collection),
|
|
174
|
+
criteria: options.criteria,
|
|
175
|
+
projectNames: scope.projectNames,
|
|
176
|
+
resolveProject: (name) => scope.projects.find((p) => sameAzureName(p.name, name)),
|
|
177
|
+
cursor: options.cursor,
|
|
178
|
+
pageSize: options.pageSize
|
|
179
|
+
}, cancellation);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Counts each scope with the WIQL its search would page through, so a count never previews constraints the read
|
|
183
|
+
* doesn't apply. A scope this provider refuses (see {@link resolveWorkItemSearchScope}) is answered as that
|
|
184
|
+
* refusal, so the facade drops only it; a request failure still fails the batch, which the facade isolates from
|
|
185
|
+
* the other batches.
|
|
186
|
+
*/
|
|
187
|
+
async countProviderIssues(session, scopes, cancellation) {
|
|
188
|
+
const azure = await this.authenticationService.apis.azure;
|
|
189
|
+
if (azure == null) return void 0;
|
|
190
|
+
const tokenWithInfo = toTokenWithInfo(this.id, session);
|
|
191
|
+
return mapBounded(scopes, azureIssueCountConcurrency, (s) => refusalAsCount(async () => {
|
|
192
|
+
this.assertSearchDates(s.criteria);
|
|
193
|
+
const scope = await this.resolveWorkItemSearchScope(session, s.repos, s.org, true);
|
|
194
|
+
return azure.countWorkItems(this, tokenWithInfo, scope.collection, {
|
|
195
|
+
baseUrl: this.collectionApiBaseUrl(session, scope.collection),
|
|
196
|
+
criteria: s.criteria,
|
|
197
|
+
projectNames: scope.projectNames
|
|
198
|
+
}, cancellation);
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The ONE collection a work-item search runs in, and the projects that bound it.
|
|
203
|
+
*
|
|
204
|
+
* A WIQL query is collection-scoped, and ordering a union of several would need every match's details rather
|
|
205
|
+
* than one page's, so a search never spans collections. `org` names the collection; without it the repositories'
|
|
206
|
+
* collection is used, and without either the only collection the account can see. Anything else — repositories
|
|
207
|
+
* in two collections, or several visible collections and nothing to choose one — is refused rather than
|
|
208
|
+
* narrowed to whichever came first.
|
|
209
|
+
*
|
|
210
|
+
* Repositories bound the query by their PROJECT, since Azure work items belong to projects rather than to
|
|
211
|
+
* repositories, and every project must be one the collection actually has: WIQL answers an unknown project name
|
|
212
|
+
* with an empty result, which would read as "nothing matched".
|
|
213
|
+
*/
|
|
214
|
+
async resolveWorkItemSearchScope(session, repos, org, refreshProjects = false) {
|
|
215
|
+
const repoCollections = uniqueAzureNames(repos?.map((r) => r.namespace) ?? []);
|
|
216
|
+
if (repoCollections.length > 1) throw this.searchUnavailable("the repositories span several collections; search each collection separately");
|
|
217
|
+
if (org && repoCollections.length === 1 && !sameAzureName(org, repoCollections[0])) throw this.searchUnavailable(`the repositories are not in the '${org}' collection`);
|
|
218
|
+
const collections = await this.getSearchCollections(session);
|
|
219
|
+
const name = org || repoCollections[0] || (collections.length === 1 ? collections[0].name : void 0);
|
|
220
|
+
if (name == null) throw this.searchUnavailable(`the account can see ${collections.length} collections; pass \`org\` to choose the one to search`);
|
|
221
|
+
const collection = findAzureCollection(collections, name);
|
|
222
|
+
if (collection == null) throw this.searchUnavailable(`the '${name}' collection is not visible`);
|
|
223
|
+
const projects = await this.getSearchProjects(session, collection, refreshProjects);
|
|
224
|
+
if (!repos?.length) return {
|
|
225
|
+
collection: collection.name,
|
|
226
|
+
projects
|
|
227
|
+
};
|
|
228
|
+
const projectNames = uniqueAzureNames(repos.map((r) => {
|
|
229
|
+
const projectName = getAzureRepositoryIdentity({
|
|
230
|
+
owner: r.namespace,
|
|
231
|
+
name: r.name,
|
|
232
|
+
project: r.project
|
|
233
|
+
}).projectName;
|
|
234
|
+
if (projectName == null) throw this.searchUnavailable(`repository '${r.namespace}/${r.name}' names no project`);
|
|
235
|
+
const project = projects.find((p) => sameAzureName(p.name, projectName));
|
|
236
|
+
if (project == null) throw this.searchUnavailable(`project '${projectName}' is not visible in '${collection.name}'`);
|
|
237
|
+
return project.name;
|
|
238
|
+
}));
|
|
239
|
+
return {
|
|
240
|
+
collection: collection.name,
|
|
241
|
+
projects,
|
|
242
|
+
projectNames
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The filtered pull-request search. Azure's pull request query can only narrow by creator, reviewer, status and
|
|
247
|
+
* repository, so every facet is drained (see {@link drainPullRequestSearch}) and the criteria it can't express
|
|
248
|
+
* are applied to the complete facets; the union is then ordered and served from a keyset cursor.
|
|
249
|
+
*/
|
|
250
|
+
async searchProviderPullRequestsPage(session, options, cancellation) {
|
|
251
|
+
this.assertSearchDates(options.criteria);
|
|
252
|
+
const sort = options.criteria?.sort ?? "updated:desc";
|
|
253
|
+
const query = toPullRequestSearchQuery(options, sort);
|
|
254
|
+
const key = toAzurePullRequestSearchCursorKey(query);
|
|
255
|
+
const cursor = parseAzurePullRequestSearchCursor(options.cursor, key);
|
|
256
|
+
const drainId = cursor?.drain ?? uuid();
|
|
257
|
+
const drainKey = this.pullRequestSearchCacheKey(session, query, drainId);
|
|
258
|
+
const drainFound = cursor != null ? this._pullRequestSearches.get(drainKey) : this.getPullRequestSearch(this._pullRequestSearches, drainKey, session, options, sort, cancellation);
|
|
259
|
+
if (drainFound == null) throw new Error("Pull request search results expired or belong to another connection; restart the read without a cursor");
|
|
260
|
+
const drain = await drainFound;
|
|
261
|
+
if (cursor == null) {
|
|
262
|
+
const latestKey = this.pullRequestSearchCacheKey(session, query);
|
|
263
|
+
this._latestPullRequestSearches.delete(latestKey);
|
|
264
|
+
this._latestPullRequestSearches.getOrCreate(latestKey, () => Promise.resolve(drain));
|
|
265
|
+
}
|
|
266
|
+
const pageSize = toAzureSearchPageSize(options.pageSize, 50, 100);
|
|
267
|
+
const start = cursor == null ? 0 : drain.rows.findIndex((r) => compareAzurePullRequestSearchPositions(r.position, cursor.after, sort) > 0);
|
|
268
|
+
const slice = start < 0 ? [] : drain.rows.slice(start, start + pageSize);
|
|
269
|
+
const page = cursor?.page ?? 1;
|
|
270
|
+
const last = slice.at(-1);
|
|
271
|
+
const hasMore = last != null && start + slice.length < drain.rows.length;
|
|
272
|
+
return {
|
|
273
|
+
values: slice.map((r) => r.pr),
|
|
274
|
+
cursor: hasMore ? JSON.stringify({
|
|
275
|
+
key,
|
|
276
|
+
drain: drainId,
|
|
277
|
+
page: page + 1,
|
|
278
|
+
after: last.position
|
|
279
|
+
}) : void 0,
|
|
280
|
+
hasMore,
|
|
281
|
+
page,
|
|
282
|
+
truncated: drain.truncated,
|
|
283
|
+
totalCount: drain.truncated ? void 0 : drain.rows.length
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Counts each scope from the latest drain of its query, the one a search's first page just read, or drains it; see
|
|
288
|
+
* {@link searchProviderPullRequestsPage}.
|
|
289
|
+
*/
|
|
290
|
+
async countProviderPullRequests(session, scopes, cancellation) {
|
|
291
|
+
return mapBounded(scopes, 1, (s) => refusalAsCount(async () => {
|
|
292
|
+
this.assertSearchDates(s.criteria);
|
|
293
|
+
const sort = s.criteria?.sort ?? "updated:desc";
|
|
294
|
+
const drain = await this.getPullRequestSearch(this._latestPullRequestSearches, this.pullRequestSearchCacheKey(session, toPullRequestSearchQuery(s, sort)), session, s, sort, cancellation);
|
|
295
|
+
return { count: drain.truncated ? void 0 : drain.rows.length };
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The drain each pagination pages through, one per first page (see {@link searchProviderPullRequestsPage}), so
|
|
300
|
+
* the 50 most recently read paginations are kept for as long as they keep reading.
|
|
301
|
+
*/
|
|
302
|
+
_pullRequestSearches = new PromiseCache({
|
|
303
|
+
capacity: 50,
|
|
304
|
+
accessTTL: pullRequestSearchIdleTtl,
|
|
305
|
+
createTTL: pullRequestSearchMaxTtl
|
|
306
|
+
});
|
|
307
|
+
/**
|
|
308
|
+
* Each query's latest drain, which its count reads for {@link pullRequestCountReuseTtl}. Apart from the
|
|
309
|
+
* paginations' drains, so counting and starting searches never evicts a pagination still being read.
|
|
310
|
+
*/
|
|
311
|
+
_latestPullRequestSearches = new PromiseCache({
|
|
312
|
+
capacity: 20,
|
|
313
|
+
createTTL: pullRequestCountReuseTtl
|
|
314
|
+
});
|
|
315
|
+
/**
|
|
316
|
+
* Keyed by the query itself, not its cursor fingerprint, so two queries whose fingerprints collide can never share
|
|
317
|
+
* a drain; by credential and installation, so two accounts or installations never read each other's results; and
|
|
318
|
+
* by `drainId` for a pagination's own drain, left out for the query's latest one.
|
|
319
|
+
*/
|
|
320
|
+
pullRequestSearchCacheKey(session, query, drainId) {
|
|
321
|
+
return JSON.stringify([
|
|
322
|
+
session.accessToken,
|
|
323
|
+
this.apiBaseUrlFor(session),
|
|
324
|
+
query,
|
|
325
|
+
drainId ?? null
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
getPullRequestSearch(cache, cacheKey, session, options, sort, cancellation) {
|
|
329
|
+
return cache.getOrCreate(cacheKey, (_cacheable, signal) => this.drainPullRequestSearch(session, options, sort, signal), { cancellation });
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Every pull request matching the search, filtered, deduplicated and ordered.
|
|
333
|
+
*
|
|
334
|
+
* One facet per (repository or project) × relationship: `Author` reads by creator, while `Assignee` and
|
|
335
|
+
* `ReviewRequested` both read by reviewer, since Azure has no assignee distinct from its reviewers. Each facet is
|
|
336
|
+
* drained to {@link azurePullRequestSearchFacetLimit}; a facet that still has more marks the whole result
|
|
337
|
+
* truncated, because its unread rows could sort anywhere in the union. A facet that fails fails the search: a
|
|
338
|
+
* union missing one facet would be served in an order it doesn't have.
|
|
339
|
+
*/
|
|
340
|
+
async drainPullRequestSearch(session, options, sort, cancellation) {
|
|
341
|
+
const criteria = options.criteria;
|
|
342
|
+
const matches = toAzurePullRequestSearchFilter(criteria);
|
|
343
|
+
const relationships = new Set(criteria?.relationships ?? []);
|
|
344
|
+
const wantAuthored = relationships.has("author");
|
|
345
|
+
const wantReviewed = relationships.has("assignee") || relationships.has("review-requested");
|
|
346
|
+
const facets = await this.resolvePullRequestSearchFacets(session, options.repos, options.org);
|
|
347
|
+
const userIds = /* @__PURE__ */ new Map();
|
|
348
|
+
if (relationships.size > 0) for (const collection of uniqueAzureNames(facets.map((f) => f.collection))) {
|
|
349
|
+
const userId = await this.getFilterUserId(session, collection);
|
|
350
|
+
if (userId == null) throw this.searchUnavailable(`the current user could not be resolved in '${collection}'`);
|
|
351
|
+
userIds.set(collection, userId);
|
|
352
|
+
}
|
|
353
|
+
const filtersFor = (collection) => {
|
|
354
|
+
if (relationships.size === 0) return [{}];
|
|
355
|
+
const userId = userIds.get(collection);
|
|
356
|
+
return [...wantAuthored ? [{ authorLogin: userId }] : [], ...wantReviewed ? [{ reviewerId: userId }] : []];
|
|
357
|
+
};
|
|
358
|
+
const api = await this.getProvidersApi();
|
|
359
|
+
const { tokenWithInfo, options: apiOptions } = this.getApiOptions(session);
|
|
360
|
+
const states = toProviderPullRequestStates(criteria?.states?.length ? criteria.states : "open");
|
|
361
|
+
const drains = await mapBounded(facets.flatMap((facet) => filtersFor(facet.collection).map((filter) => ({
|
|
362
|
+
facet,
|
|
363
|
+
filter
|
|
364
|
+
}))), 6, async ({ facet, filter }) => {
|
|
365
|
+
const baseUrl = this.collectionApiBaseUrl(session, facet.collection);
|
|
366
|
+
const values = [];
|
|
367
|
+
let page = 1;
|
|
368
|
+
for (let read = 1;; read++) {
|
|
369
|
+
if (cancellation?.aborted) throw new CancellationError();
|
|
370
|
+
const input = {
|
|
371
|
+
...apiOptions,
|
|
372
|
+
...filter,
|
|
373
|
+
states,
|
|
374
|
+
page,
|
|
375
|
+
pageSize: azurePullRequestSearchPageSize,
|
|
376
|
+
baseUrl
|
|
377
|
+
};
|
|
378
|
+
const result = facet.repository != null ? await api.getPullRequestsForRepo(tokenWithInfo, {
|
|
379
|
+
namespace: facet.collection,
|
|
380
|
+
name: facet.repository,
|
|
381
|
+
project: facet.project.name
|
|
382
|
+
}, input).then((r) => ({
|
|
383
|
+
values: r.values,
|
|
384
|
+
more: r.paging?.more === true,
|
|
385
|
+
nextPage: r.paging?.nextPage
|
|
386
|
+
})) : await api.getPullRequestsForAzureProject(tokenWithInfo, {
|
|
387
|
+
namespace: facet.collection,
|
|
388
|
+
project: facet.project.name
|
|
389
|
+
}, input).then((r) => r != null ? {
|
|
390
|
+
values: r.data,
|
|
391
|
+
more: r.hasMore,
|
|
392
|
+
nextPage: r.nextPage ?? void 0
|
|
393
|
+
} : void 0);
|
|
394
|
+
if (result == null) throw new Error("Azure DevOps returned no pull request page");
|
|
395
|
+
values.push(...result.values);
|
|
396
|
+
if (!result.more) return {
|
|
397
|
+
facet,
|
|
398
|
+
values,
|
|
399
|
+
truncated: false
|
|
400
|
+
};
|
|
401
|
+
if (result.nextPage == null || result.nextPage <= page) throw new Error("Azure DevOps returned no advancing pull request continuation");
|
|
402
|
+
if (read * azurePullRequestSearchPageSize >= azurePullRequestSearchFacetLimit) return {
|
|
403
|
+
facet,
|
|
404
|
+
values,
|
|
405
|
+
truncated: true
|
|
406
|
+
};
|
|
407
|
+
page = result.nextPage;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
const rows = /* @__PURE__ */ new Map();
|
|
411
|
+
for (const { facet, values } of drains) for (const pr of values) {
|
|
412
|
+
if (!matches(pr)) continue;
|
|
413
|
+
const identity = getProviderPullRequestIdentity(pr) ?? `project:${facet.collection}/${facet.project.name}:${pr.id}`;
|
|
414
|
+
if (rows.has(identity)) continue;
|
|
415
|
+
rows.set(identity, {
|
|
416
|
+
pr: fromProviderPullRequest(pr, this, {
|
|
417
|
+
project: facet.project,
|
|
418
|
+
currentAccountId: userIds.get(facet.collection)
|
|
419
|
+
}),
|
|
420
|
+
position: toAzurePullRequestSearchPosition(pr, sort, identity)
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
rows: [...rows.values()].sort((a, b) => compareAzurePullRequestSearchPositions(a.position, b.position, sort)),
|
|
425
|
+
truncated: drains.some((d) => d.truncated)
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The repositories or projects a pull-request search reads, each with its collection and project descriptor.
|
|
430
|
+
*
|
|
431
|
+
* Repositories are read one by one; an `org` is its collection's projects; a search bounded only by the current
|
|
432
|
+
* user reads every project the account can see, the same breadth as the account-wide pull request read.
|
|
433
|
+
*/
|
|
434
|
+
async resolvePullRequestSearchFacets(session, repos, org) {
|
|
435
|
+
const collections = await this.getSearchCollections(session);
|
|
436
|
+
const collectionFor = (name) => {
|
|
437
|
+
const collection = findAzureCollection(collections, name);
|
|
438
|
+
if (collection == null) throw this.searchUnavailable(`the '${name}' collection is not visible`);
|
|
439
|
+
return collection;
|
|
440
|
+
};
|
|
441
|
+
if (repos?.length) {
|
|
442
|
+
if (org && repos.some((r) => !sameAzureName(r.namespace, org))) throw this.searchUnavailable(`the repositories are not all in the '${org}' collection`);
|
|
443
|
+
const projectsByCollection = /* @__PURE__ */ new Map();
|
|
444
|
+
const projectsOf = (collection) => {
|
|
445
|
+
let projects = projectsByCollection.get(collection.id);
|
|
446
|
+
if (projects == null) {
|
|
447
|
+
projects = this.getSearchProjects(session, collection, true);
|
|
448
|
+
projectsByCollection.set(collection.id, projects);
|
|
449
|
+
}
|
|
450
|
+
return projects;
|
|
451
|
+
};
|
|
452
|
+
return Promise.all(repos.map(async (r) => {
|
|
453
|
+
const collection = collectionFor(r.namespace);
|
|
454
|
+
const identity = getAzureRepositoryIdentity({
|
|
455
|
+
owner: r.namespace,
|
|
456
|
+
name: r.name,
|
|
457
|
+
project: r.project
|
|
458
|
+
});
|
|
459
|
+
if (identity.projectName == null) throw this.searchUnavailable(`repository '${r.namespace}/${r.name}' names no project`);
|
|
460
|
+
if (!isAzureRouteSegment(identity.repositoryName)) throw this.searchUnavailable(`repository '${r.namespace}/${r.name}' is not a valid name`);
|
|
461
|
+
const project = (await projectsOf(collection)).find((p) => sameAzureName(p.name, identity.projectName));
|
|
462
|
+
if (project == null) throw this.searchUnavailable(`project '${identity.projectName}' is not visible in '${collection.name}'`);
|
|
463
|
+
return {
|
|
464
|
+
collection: collection.name,
|
|
465
|
+
project,
|
|
466
|
+
repository: identity.repositoryName
|
|
467
|
+
};
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
const scoped = org ? [collectionFor(org)] : collections;
|
|
471
|
+
return (await Promise.all(scoped.map((c) => this.getSearchProjects(session, c, true)))).flat().map((p) => ({
|
|
472
|
+
collection: p.resourceName,
|
|
473
|
+
project: p
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
_collectionUserIds = new PromiseCache({ capacity: 50 });
|
|
477
|
+
/**
|
|
478
|
+
* The current user's identity id in `collection`, read from the collection's own connection data, which is
|
|
479
|
+
* what Azure's creator and reviewer filters compare against. Cached per credential and collection, since it
|
|
480
|
+
* can't change for either.
|
|
481
|
+
*/
|
|
482
|
+
getFilterUserId(session, collection) {
|
|
483
|
+
const baseUrl = this.collectionApiBaseUrl(session, collection);
|
|
484
|
+
return this._collectionUserIds.getOrCreate(JSON.stringify([
|
|
485
|
+
session.accessToken,
|
|
486
|
+
baseUrl,
|
|
487
|
+
collection.toLowerCase()
|
|
488
|
+
]), async (cacheable) => {
|
|
489
|
+
const user = await (await this.authenticationService.apis.azure)?.getCurrentUserOnServer(this, toTokenWithInfo(this.id, session), `${baseUrl}/${encodeURIComponent(collection)}`);
|
|
490
|
+
if (user?.id == null) cacheable.invalidate();
|
|
491
|
+
return user?.id;
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/** Every collection the account can see, or a refusal when discovery itself failed. */
|
|
495
|
+
async getSearchCollections(session) {
|
|
496
|
+
const collections = await this.getProviderResourcesForUser(session);
|
|
497
|
+
if (collections == null) throw this.searchUnavailable("the account’s collections could not be read");
|
|
498
|
+
return collections;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* A collection's projects, refused when discovery was incomplete: a search bounded by an incomplete project set
|
|
502
|
+
* would silently omit the missing projects' matches. `refresh` re-reads them rather than trusting the discovery
|
|
503
|
+
* cache, which lives for the whole connection and so doesn't know a project created since.
|
|
504
|
+
*/
|
|
505
|
+
async getSearchProjects(session, collection, refresh = false) {
|
|
506
|
+
const projects = refresh ? await this.refreshSearchProjects(session, collection) : await this.getProviderProjectsForResources(session, [collection]);
|
|
507
|
+
if (projects.metadata != null && projects.metadata.completeness !== "complete") throw this.searchUnavailable(`the projects of the '${collection.name}' collection could not all be read`);
|
|
508
|
+
return projects.values;
|
|
509
|
+
}
|
|
510
|
+
_projectRefreshes = /* @__PURE__ */ new Map();
|
|
511
|
+
/**
|
|
512
|
+
* Re-reads a collection's projects, sharing one read among the refreshes IN FLIGHT at once — a count batch or a
|
|
513
|
+
* multi-repository drain refreshes the same collection once per scope, and each would otherwise re-read every
|
|
514
|
+
* project page. Nothing outlives the read, so the next search still sees a project created after it.
|
|
515
|
+
*/
|
|
516
|
+
refreshSearchProjects(session, collection) {
|
|
517
|
+
const key = `${this.discoveryKey(session)}:${collection.id}`;
|
|
518
|
+
let pending = this._projectRefreshes.get(key);
|
|
519
|
+
if (pending == null) {
|
|
520
|
+
pending = this.getProviderProjectsForResources(session, [collection], true).finally(() => {
|
|
521
|
+
this._projectRefreshes.delete(key);
|
|
522
|
+
});
|
|
523
|
+
this._projectRefreshes.set(key, pending);
|
|
524
|
+
}
|
|
525
|
+
return pending;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Refuses dates the search can't apply before anything is read. The facade only checks that dates are
|
|
529
|
+
* supported, not their shape, and a refusal here stays with its own scope in a count batch.
|
|
530
|
+
*/
|
|
531
|
+
assertSearchDates(criteria) {
|
|
532
|
+
const invalid = getInvalidAzureSearchDate(criteria);
|
|
533
|
+
if (invalid != null) throw this.searchUnavailable(invalid);
|
|
534
|
+
}
|
|
535
|
+
searchUnavailable(reason) {
|
|
536
|
+
return new IntegrationReadUnavailableError(this.name, `search refused: ${reason}.`);
|
|
537
|
+
}
|
|
538
|
+
providerOnDisconnect() {
|
|
539
|
+
super.providerOnDisconnect();
|
|
540
|
+
this._pullRequestSearches.clear();
|
|
541
|
+
this._latestPullRequestSearches.clear();
|
|
542
|
+
this._collectionUserIds.clear();
|
|
543
|
+
}
|
|
544
|
+
async _requestForCurrentUser(session) {
|
|
545
|
+
const azure = await this.authenticationService.apis.azure;
|
|
546
|
+
const user = azure ? await azure.getCurrentUserOnServer(this, toTokenWithInfo(this.id, session), this.apiBaseUrlFor(session)) : void 0;
|
|
547
|
+
return user ? {
|
|
548
|
+
provider: this,
|
|
549
|
+
id: user.id,
|
|
550
|
+
name: user.name ?? void 0,
|
|
551
|
+
email: user.email ?? void 0,
|
|
552
|
+
avatarUrl: user.avatarUrl ?? void 0,
|
|
553
|
+
username: user.username ?? void 0
|
|
554
|
+
} : void 0;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
//#endregion
|
|
558
|
+
export { AzureDevOpsServerIntegration };
|
|
559
|
+
|
|
560
|
+
//# sourceMappingURL=azureDevOpsServer.js.map
|