@gitkraken/core-gitlens 0.5.115 → 0.5.116
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 +13 -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/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/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 +26 -2
- package/package.json +1 -1
- package/src/plus/git-github/api/github.ts +3 -2
- package/src/plus/integrations/manager.ts +43 -6
- 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/searchIssues.ts +23 -13
- package/src/plus/integrations/reads/searchPullRequests.ts +16 -2
- package/src/plus/integrations/reads/warnings.ts +19 -0
|
@@ -404,15 +404,36 @@ export interface IntegrationManager {
|
|
|
404
404
|
*/
|
|
405
405
|
searchPullRequestsPage(options: {
|
|
406
406
|
providerId: IntegrationIds;
|
|
407
|
-
/**
|
|
407
|
+
/**
|
|
408
|
+
* Repository descriptors that bound the search; ids cannot name provider search qualifiers.
|
|
409
|
+
*
|
|
410
|
+
* Both halves of a descriptor must reach the provider unchanged — see `org` — since a search names a
|
|
411
|
+
* repository by its `namespace/name` path.
|
|
412
|
+
*/
|
|
408
413
|
repos?: ProviderRepositoriesInput;
|
|
409
|
-
/**
|
|
414
|
+
/**
|
|
415
|
+
* Organization/account that bounds the search.
|
|
416
|
+
*
|
|
417
|
+
* Held to a STRICTER rule than the free-form `criteria.text`, which is sanitized: a scope name carrying a
|
|
418
|
+
* quote, or an inner space or control character, is REFUSED (warning + `fetchFailed`), and the refusal
|
|
419
|
+
* names the value. Leading and trailing whitespace and control characters are stripped and accepted,
|
|
420
|
+
* since removing them cannot change which scope the query names. Sanitizing a scope would answer the wrong question — the sanitized value may name a real but
|
|
421
|
+
* DIFFERENT organization, whose result looks entirely normal. Pass the name exactly as the provider spells
|
|
422
|
+
* it; `''` means "no org supplied" and falls through to the other scopes.
|
|
423
|
+
*/
|
|
410
424
|
org?: string;
|
|
411
425
|
criteria?: PullRequestSearchCriteria;
|
|
412
426
|
/** Cursor-only: without a cursor, reaching page N costs O(N) upstream requests. */
|
|
413
427
|
page?: number;
|
|
414
428
|
cursor?: string;
|
|
415
|
-
/**
|
|
429
|
+
/**
|
|
430
|
+
* Page size PER RELATIONSHIP × STATE facet, not per page. Each facet is its own aliased provider query —
|
|
431
|
+
* one axis more than {@link searchIssuesPage}'s per-relationship fan-out — so a page of a 3-relationship,
|
|
432
|
+
* 2-state search returns up to `6 × itemsPerPage` items before deduplication, and fewer than that where
|
|
433
|
+
* the facets overlap. Deduplication does NOT bring the page back to this size: it removes only the rows the
|
|
434
|
+
* facets share. `page.itemsPerPage` reports what actually came back, so size the UI off that rather than
|
|
435
|
+
* off this. A provider may also cap it below what is asked for.
|
|
436
|
+
*/
|
|
416
437
|
itemsPerPage?: number;
|
|
417
438
|
forceSync?: boolean;
|
|
418
439
|
connectionId?: string;
|
|
@@ -420,7 +441,9 @@ export interface IntegrationManager {
|
|
|
420
441
|
domain?: string;
|
|
421
442
|
/**
|
|
422
443
|
* Requests the lightweight row shape: identity, body, author, repository, branch refs and stack info,
|
|
423
|
-
* without review, check or diff statistics. It also
|
|
444
|
+
* without review, check or diff statistics. It also replaces the flat per-facet default page size with a
|
|
445
|
+
* fixed budget shared across the search's ACTIVE facets, so with up to three of them it raises the page
|
|
446
|
+
* and from four on it LOWERS it — measured against GitHub, a 100-row budget versus a flat 30 each.
|
|
424
447
|
*
|
|
425
448
|
* A provider without a lightweight projection ignores it and returns its usual shape, so this is a hint
|
|
426
449
|
* rather than a contract about which fields are present.
|
|
@@ -513,9 +536,23 @@ export interface IntegrationManager {
|
|
|
513
536
|
*/
|
|
514
537
|
searchIssuesPage(options: {
|
|
515
538
|
providerId: IntegrationIds;
|
|
516
|
-
/**
|
|
539
|
+
/**
|
|
540
|
+
* Repositories to search. Combines with `org`; both constrain the same query.
|
|
541
|
+
*
|
|
542
|
+
* Both halves of a descriptor must reach the provider unchanged — see `org` — since a search names a
|
|
543
|
+
* repository by its `namespace/name` path.
|
|
544
|
+
*/
|
|
517
545
|
repos?: ProviderRepositoriesInput;
|
|
518
|
-
/**
|
|
546
|
+
/**
|
|
547
|
+
* Organization/account to search. Combines with `repos`.
|
|
548
|
+
*
|
|
549
|
+
* Held to a STRICTER rule than the free-form criteria, which are sanitized: a scope name carrying a quote,
|
|
550
|
+
* or an inner space or control character, is REFUSED (warning + `fetchFailed`), and the refusal names the
|
|
551
|
+
* value. Leading and trailing whitespace and control characters are stripped and accepted, since removing
|
|
552
|
+
* them cannot change which scope the query names.
|
|
553
|
+
* The sanitized value may name a real but DIFFERENT org, whose result looks entirely normal. `''` means
|
|
554
|
+
* "no org supplied" and falls through to the other scopes.
|
|
555
|
+
*/
|
|
519
556
|
org?: string;
|
|
520
557
|
criteria?: IssueSearchCriteria;
|
|
521
558
|
/**
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
otherWarning,
|
|
26
26
|
truncationWarning,
|
|
27
27
|
unsupportedIssueSearchCriteriaWarning,
|
|
28
|
+
unusableSearchScopeMessage,
|
|
28
29
|
} from './warnings.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -350,21 +351,6 @@ interface OrgIssuesRead {
|
|
|
350
351
|
nothingToRead?: boolean;
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
/**
|
|
354
|
-
* Whether this org can be read through the FILTERED issue search instead of the repository drain.
|
|
355
|
-
*
|
|
356
|
-
* Two conditions, and the second is not a formality: an empty org name is dropped by the provider's scope
|
|
357
|
-
* translation rather than rejected, which would leave a search of the WHOLE HOST — so the scope is validated
|
|
358
|
-
* through {@link resolveIssueSearchScope}, the same rule `searchIssuesPage` refuses on, rather than by an
|
|
359
|
-
* `org.name.length > 0` written here and free to drift from it.
|
|
360
|
-
*/
|
|
361
|
-
function canSearchOrgIssues(org: ProviderBroadenOrg): boolean {
|
|
362
|
-
return (
|
|
363
|
-
supportsFilteredIssueSearch(org.providerId) &&
|
|
364
|
-
resolveIssueSearchScope(undefined, org.name, undefined).rejection == null
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
354
|
/**
|
|
369
355
|
* The org-scoped filtered search: ONE request per page, whatever the org contains.
|
|
370
356
|
*
|
|
@@ -602,6 +588,34 @@ async function readOrgSlice(
|
|
|
602
588
|
);
|
|
603
589
|
}
|
|
604
590
|
|
|
591
|
+
// An org name NEITHER engine can use, refused before either is chosen. Declining the search for it is not
|
|
592
|
+
// enough, because falling through to the repository drain is not the safe default it looks like: the drain
|
|
593
|
+
// passes the SAME name to the org repository read, which matches nothing and reports `nothingToRead` — an
|
|
594
|
+
// org that reads as empty, with no warning and no `fetchFailed`, after up to 100 requests. Indistinguishable
|
|
595
|
+
// from an org that genuinely has no issues, and strictly worse than saying so.
|
|
596
|
+
//
|
|
597
|
+
// EVERY rejection, not just `unusable-scope`: with no repositories and no criteria the only other reachable
|
|
598
|
+
// one is `unscoped`, which here means exactly `org.name === ''` — the same bad outcome by the same route, so
|
|
599
|
+
// drawing the line between them would leave the emptiest case on the wrong side of the rule. (`repo-ids`
|
|
600
|
+
// cannot arise: no repositories are passed.) Refusing them all is also what lets the engine choice below be
|
|
601
|
+
// a plain capability question again, since no rejection survives this point.
|
|
602
|
+
const scopeRejection = resolveIssueSearchScope(undefined, org.name, undefined).rejection;
|
|
603
|
+
if (scopeRejection != null) {
|
|
604
|
+
return barrenSlice(
|
|
605
|
+
[
|
|
606
|
+
otherWarning(
|
|
607
|
+
org.providerId,
|
|
608
|
+
ctx.domainForRead(integration, org.providerId, connectionId, requestedDomain),
|
|
609
|
+
connectionId,
|
|
610
|
+
scopeRejection.reason === 'unusable-scope'
|
|
611
|
+
? unusableSearchScopeMessage('issue broadening scopes', scopeRejection.scopes)
|
|
612
|
+
: 'Issue broadening must name an organization; one was supplied with an empty name.',
|
|
613
|
+
),
|
|
614
|
+
],
|
|
615
|
+
{ fetchFailed: true },
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
605
619
|
// An org a prior round already drained must not be re-read: cursor-only providers would answer a
|
|
606
620
|
// fresh page-1 request with their first page again, duplicating issues across rounds. Skip it
|
|
607
621
|
// before any work (including the repo drain) and keep it marked exhausted so it stays skipped
|
|
@@ -614,7 +628,12 @@ async function readOrgSlice(
|
|
|
614
628
|
|
|
615
629
|
const domain = ctx.domainForRead(integration, org.providerId, connectionId, requestedDomain);
|
|
616
630
|
const cursor = getBroadenIssuesCursor(options.cursor, org, page, options.orgs.length);
|
|
617
|
-
|
|
631
|
+
// A plain capability question, and only because the refusal above has already turned away every org whose
|
|
632
|
+
// name the scope rule rejects. That order matters: `readOrgIssuesViaSearch` calls the integration's
|
|
633
|
+
// `searchIssuesPageResult` directly rather than the `searchIssuesPage` read, and that method documents its
|
|
634
|
+
// scope as already validated ("the facade rejects it before here") — so an unvalidated name reaching it would
|
|
635
|
+
// go straight to the provider query that sanitizes it.
|
|
636
|
+
const read = supportsFilteredIssueSearch(org.providerId)
|
|
618
637
|
? await readOrgIssuesViaSearch(integration, org, domain, cursor)
|
|
619
638
|
: await readOrgIssuesViaRepoDrain(integration, org, domain, cursor);
|
|
620
639
|
if (read.nothingToRead === true) {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
otherWarning,
|
|
28
28
|
unsupportedIssueSearchCriteriaWarning,
|
|
29
29
|
unsupportedPullRequestSearchCriteriaWarning,
|
|
30
|
+
unusableSearchScopeMessage,
|
|
30
31
|
} from './warnings.js';
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -199,6 +200,23 @@ export async function countIssues(
|
|
|
199
200
|
return refused(issuesUnsupportedWarning(options.providerId, domain, options.connectionId));
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
// A provider with no filtered issue search has no count either: refuse ONCE for the provider rather than
|
|
204
|
+
// letting every scope repeat the same rejection. `undefined` criteria probes only the search's existence.
|
|
205
|
+
// Matches `countPullRequests`. Refusing it here rather than per scope also keeps the loop's warnings from
|
|
206
|
+
// being dominated by N copies of one provider-level fact — see the dedupe below, which handles the
|
|
207
|
+
// keyless refusals that remain.
|
|
208
|
+
const searchSupport = resolveIssueSearchCriteria(options.providerId, undefined);
|
|
209
|
+
if (searchSupport.rejection != null) {
|
|
210
|
+
return refused(
|
|
211
|
+
unsupportedIssueSearchCriteriaWarning(
|
|
212
|
+
options.providerId,
|
|
213
|
+
domain,
|
|
214
|
+
options.connectionId,
|
|
215
|
+
searchSupport.rejection,
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
202
220
|
const providerLimit = providersMetadata[options.providerId]?.issueSearchResultLimit;
|
|
203
221
|
const warnings: ProviderWarning[] = [];
|
|
204
222
|
let fetchFailed = false;
|
|
@@ -209,10 +227,12 @@ export async function countIssues(
|
|
|
209
227
|
for (const scope of options.scopes) {
|
|
210
228
|
const warning = rejectScope(options.providerId, domain, options.connectionId, scope);
|
|
211
229
|
if (warning != null) {
|
|
212
|
-
//
|
|
213
|
-
// keys were already refused above, so
|
|
214
|
-
//
|
|
215
|
-
warnings
|
|
230
|
+
// Deduped rather than pushed. The rejections that name the offending SCOPE embed its own key, and
|
|
231
|
+
// duplicate keys were already refused above, so those can never collapse. But the ones that report a
|
|
232
|
+
// PROVIDER-level fact — a criterion it cannot express, a contradictory relationship pair — name no
|
|
233
|
+
// scope, so several scopes failing the same way produce byte-identical warnings, and one refusal
|
|
234
|
+
// reported N times reads as N different problems.
|
|
235
|
+
appendDedupedWarning(warnings, warning);
|
|
216
236
|
fetchFailed = true;
|
|
217
237
|
continue;
|
|
218
238
|
}
|
|
@@ -304,8 +324,15 @@ function rejectScope(
|
|
|
304
324
|
connectionId: string | undefined,
|
|
305
325
|
scope: IssueCountScope,
|
|
306
326
|
): ProviderWarning | undefined {
|
|
327
|
+
const resolved = resolveIssueSearchCriteria(providerId, scope.criteria);
|
|
328
|
+
if (resolved.rejection != null) {
|
|
329
|
+
return unsupportedIssueSearchCriteriaWarning(providerId, domain, connectionId, resolved.rejection);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// AFTER the criteria check, mirroring `searchIssuesPage`: a provider with no filtered issue search is the more
|
|
333
|
+
// fundamental refusal, and a count must preview the refusal its read would give.
|
|
307
334
|
const scoping = resolveIssueSearchScope(scope.repos, scope.org, scope.criteria);
|
|
308
|
-
switch (scoping.rejection) {
|
|
335
|
+
switch (scoping.rejection?.reason) {
|
|
309
336
|
case 'repo-ids':
|
|
310
337
|
return otherWarning(
|
|
311
338
|
providerId,
|
|
@@ -320,11 +347,15 @@ function rejectScope(
|
|
|
320
347
|
connectionId,
|
|
321
348
|
`Issue count scope '${scope.key}' is unscoped; pass \`repos\`, \`org\`, or a relationship to the current user. \`any-assignee\` and \`unassigned\` are not scopes.`,
|
|
322
349
|
);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
350
|
+
// A count applies exactly the qualifiers its search would, so an unusable scope would make it agree with
|
|
351
|
+
// the wrong search rather than expose it — see `isUsableSearchScopeName`.
|
|
352
|
+
case 'unusable-scope':
|
|
353
|
+
return otherWarning(
|
|
354
|
+
providerId,
|
|
355
|
+
domain,
|
|
356
|
+
connectionId,
|
|
357
|
+
unusableSearchScopeMessage('scopes', scoping.rejection.scopes, `Issue count scope '${scope.key}'`),
|
|
358
|
+
);
|
|
328
359
|
}
|
|
329
360
|
|
|
330
361
|
// Count-only, with no counterpart in the read: a relationship set is an OR across several searches, which one
|
|
@@ -435,9 +466,11 @@ export async function countPullRequests(
|
|
|
435
466
|
for (const scope of options.scopes) {
|
|
436
467
|
const warning = rejectPullRequestScope(options.providerId, domain, options.connectionId, scope);
|
|
437
468
|
if (warning != null) {
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
469
|
+
// Deduped rather than pushed: see the issue twin. Here the keyless set is wider — besides the criteria
|
|
470
|
+
// rejections, `unsupported-repository-scope` and `unsupported-organization-scope` are scope rejections
|
|
471
|
+
// that route through the criteria warning and name no key, and both are provider-level facts every
|
|
472
|
+
// scope would repeat.
|
|
473
|
+
appendDedupedWarning(warnings, warning);
|
|
441
474
|
fetchFailed = true;
|
|
442
475
|
continue;
|
|
443
476
|
}
|
|
@@ -516,7 +549,7 @@ function rejectPullRequestScope(
|
|
|
516
549
|
scope: PullRequestCountScope,
|
|
517
550
|
): ProviderWarning | undefined {
|
|
518
551
|
const scoping = resolvePullRequestSearchScope(providerId, scope.repos, scope.org, scope.criteria);
|
|
519
|
-
switch (scoping.rejection) {
|
|
552
|
+
switch (scoping.rejection?.reason) {
|
|
520
553
|
case 'repo-ids':
|
|
521
554
|
return otherWarning(
|
|
522
555
|
providerId,
|
|
@@ -536,9 +569,23 @@ function rejectPullRequestScope(
|
|
|
536
569
|
return unsupportedPullRequestSearchCriteriaWarning(providerId, domain, connectionId, {
|
|
537
570
|
reason: 'unsupported-criteria',
|
|
538
571
|
criteria: [
|
|
539
|
-
scoping.rejection === 'unsupported-repository-scope'
|
|
572
|
+
scoping.rejection.reason === 'unsupported-repository-scope'
|
|
573
|
+
? 'repositoryScope'
|
|
574
|
+
: 'organizationScope',
|
|
540
575
|
],
|
|
541
576
|
});
|
|
577
|
+
// See the issue twin, and `isUsableSearchScopeName` for the rule.
|
|
578
|
+
case 'unusable-scope':
|
|
579
|
+
return otherWarning(
|
|
580
|
+
providerId,
|
|
581
|
+
domain,
|
|
582
|
+
connectionId,
|
|
583
|
+
unusableSearchScopeMessage(
|
|
584
|
+
'scopes',
|
|
585
|
+
scoping.rejection.scopes,
|
|
586
|
+
`Pull request count scope '${scope.key}'`,
|
|
587
|
+
),
|
|
588
|
+
);
|
|
542
589
|
}
|
|
543
590
|
|
|
544
591
|
const resolved = resolvePullRequestSearchCriteria(providerId, scope.criteria);
|
|
@@ -129,14 +129,19 @@ export function resolvePullRequestSearchCriteria(
|
|
|
129
129
|
|
|
130
130
|
/** Why a filtered pull-request search's repository/organization boundary was refused. */
|
|
131
131
|
export type PullRequestSearchScopeRejection =
|
|
132
|
-
| 'unscoped'
|
|
133
|
-
| 'repo-ids'
|
|
134
|
-
| 'unsupported-repository-scope'
|
|
135
|
-
| 'unsupported-organization-scope'
|
|
132
|
+
| { reason: 'unscoped' }
|
|
133
|
+
| { reason: 'repo-ids' }
|
|
134
|
+
| { reason: 'unsupported-repository-scope' }
|
|
135
|
+
| { reason: 'unsupported-organization-scope' }
|
|
136
|
+
/** Scope names a query cannot carry AS GIVEN; see {@link unusableSearchScopeNames}. */
|
|
137
|
+
| { reason: 'unusable-scope'; scopes: string[] };
|
|
136
138
|
|
|
137
139
|
/**
|
|
138
140
|
* Validates the search boundary independently from its criteria. A current-user relationship is itself a safe
|
|
139
141
|
* account-wide boundary; without one, a repository or organization scope is mandatory.
|
|
142
|
+
*
|
|
143
|
+
* Scope names go through the same {@link unusableSearchScopeNames} rule the issue search uses — shared rather
|
|
144
|
+
* than re-derived, because the defect is identical on both reads.
|
|
140
145
|
*/
|
|
141
146
|
export function resolvePullRequestSearchScope(
|
|
142
147
|
id: IntegrationIds,
|
|
@@ -148,22 +153,32 @@ export function resolvePullRequestSearchScope(
|
|
|
148
153
|
let resolvedRepos: ProviderRepoInput[] | undefined;
|
|
149
154
|
|
|
150
155
|
if (repos?.length) {
|
|
151
|
-
if (repos.some(r => typeof r === 'string' || typeof r === 'number'))
|
|
152
|
-
|
|
156
|
+
if (repos.some(r => typeof r === 'string' || typeof r === 'number')) {
|
|
157
|
+
return { rejection: { reason: 'repo-ids' } };
|
|
158
|
+
}
|
|
159
|
+
if (supported?.repositoryScope !== true) return { rejection: { reason: 'unsupported-repository-scope' } };
|
|
153
160
|
|
|
154
161
|
resolvedRepos = repos as ProviderRepoInput[];
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
const hasOrganizationScope = org != null && org.length > 0;
|
|
158
165
|
if (hasOrganizationScope && supported?.organizationScope !== true) {
|
|
159
|
-
return { rejection: 'unsupported-organization-scope' };
|
|
166
|
+
return { rejection: { reason: 'unsupported-organization-scope' } };
|
|
160
167
|
}
|
|
161
168
|
|
|
169
|
+
// After the capability checks and before the "is it scoped at all" rule: a scope the provider cannot express
|
|
170
|
+
// at all is the more fundamental refusal, and an unusable value that WAS supplied must not be reported as a
|
|
171
|
+
// missing one. `org` is passed bare, matching the issue twin — the helper applies its own empty-means-
|
|
172
|
+
// unsupplied guard, so re-testing `hasOrganizationScope` here would only make the two resolvers look like
|
|
173
|
+
// they differ.
|
|
174
|
+
const unusable = unusableSearchScopeNames(org, resolvedRepos);
|
|
175
|
+
if (unusable.length > 0) return { rejection: { reason: 'unusable-scope', scopes: unusable } };
|
|
176
|
+
|
|
162
177
|
if (resolvedRepos != null || hasOrganizationScope || (criteria?.relationships?.length ?? 0) > 0) {
|
|
163
178
|
return { repos: resolvedRepos };
|
|
164
179
|
}
|
|
165
180
|
|
|
166
|
-
return { rejection: 'unscoped' };
|
|
181
|
+
return { rejection: { reason: 'unscoped' } };
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
/**
|
|
@@ -321,25 +336,105 @@ const unsupportedPullRequestSearchCapabilities: PullRequestSearchCapabilities =
|
|
|
321
336
|
/** Why a filtered issue search's scope was refused, or `undefined` when it is usable. */
|
|
322
337
|
export type IssueSearchScopeRejection =
|
|
323
338
|
/** No repositories, no org, and no user-relative relationship: a search of the whole host. */
|
|
324
|
-
| 'unscoped'
|
|
339
|
+
| { reason: 'unscoped' }
|
|
325
340
|
/** Repositories given as ids. A search names repositories by PATH, so ids can't express a scope. */
|
|
326
|
-
| 'repo-ids'
|
|
341
|
+
| { reason: 'repo-ids' }
|
|
342
|
+
/** Scope names a query cannot carry AS GIVEN; see {@link unusableSearchScopeNames}. */
|
|
343
|
+
| { reason: 'unusable-scope'; scopes: string[] };
|
|
327
344
|
|
|
328
345
|
/**
|
|
329
|
-
* Whether a scope name
|
|
346
|
+
* Whether a scope name reaches the provider NAMING THE SAME SCOPE — the one rule behind every `unusable-scope`
|
|
347
|
+
* refusal, and the canonical home for its reasoning: the call sites point here rather than restating it.
|
|
348
|
+
*
|
|
349
|
+
* The defect it exists to prevent: the boundary is checked against the value AS SUPPLIED while the request is
|
|
350
|
+
* built from the value AFTER the provider sanitizes it, so a name a query cannot spell produces a read that is no
|
|
351
|
+
* longer the read that was authorized. Three outcomes, all of which LOOK LIKE SUCCESS, so there is nothing for a
|
|
352
|
+
* consumer to branch on:
|
|
353
|
+
* - **emptied** — a value of only quotes/whitespace/control characters emits no scope qualifier at all. A scope is
|
|
354
|
+
* also what makes a relationship-less read legal, so the request carries neither and every item on the host
|
|
355
|
+
* matches: measured at 52 million issues across unrelated accounts.
|
|
356
|
+
* - **altered** — a quote inside a real name sanitizes to a real but DIFFERENT scope (`git"kraken` -> `gitkraken`),
|
|
357
|
+
* whose answer looks entirely normal.
|
|
358
|
+
* - **split** — whitespace delimits qualifiers, so `my org` emits `org:my org`: a search of `my` additionally
|
|
359
|
+
* filtered by the free text `org`. Measured against the live API this is a wrong NARROWING rather than a
|
|
360
|
+
* widening — `org:gitkraken bar` returns 12 where `org:gitkraken` returns 379.
|
|
361
|
+
*
|
|
362
|
+
* Refusing beats sanitizing, and the count probes are why it matters most: they deliberately apply exactly the
|
|
363
|
+
* qualifiers their search would, so a sanitized scope makes the count AGREE with the wrong search rather than
|
|
364
|
+
* disagree with it, and a consumer cross-checking "N matched" cannot detect it by construction. Nor can it
|
|
365
|
+
* pre-empt the rule — the free-text sanitizing rules are published precisely so a caller can mirror them, the
|
|
366
|
+
* scope rules are not — so only the caller knows which scope it meant, and the refusal goes back to it NAMING the
|
|
367
|
+
* offending value.
|
|
330
368
|
*
|
|
331
369
|
* Provider-NEUTRAL by design, and deliberately not an import of GitHub's `sanitizeGitHubQualifierValue`: this
|
|
332
370
|
* module validates for every provider, and a GitHub-specific rule reaching in here would be wrong for the next
|
|
333
|
-
* one that declares a search.
|
|
334
|
-
*
|
|
335
|
-
*
|
|
371
|
+
* one that declares a search. Only GitHub and GHE declare one today, so the character class below is GitHub's in
|
|
372
|
+
* practice; it is stated as the common part of any query language because each class breaks a query on its own
|
|
373
|
+
* terms — a quote closes its own qualifier, a control character cannot appear at all, whitespace delimits the
|
|
374
|
+
* next qualifier — but a provider whose names legitimately carry one (Azure DevOps project names can contain
|
|
375
|
+
* spaces) needs its own rule alongside its `supported*Search` capability rather than an exception here.
|
|
376
|
+
*
|
|
377
|
+
* EDGES ARE STRIPPED before the test, which is what keeps the predicate no stricter than the provider's own
|
|
378
|
+
* sanitizing — the invariant that makes refusing safe to add, since it means this can only reject a name the
|
|
379
|
+
* provider would have altered, never one it would have resolved correctly. Leading and trailing whitespace AND
|
|
380
|
+
* control characters both qualify: a sanitizer maps a control character to a space, then collapses and trims, so
|
|
381
|
+
* `'gitkraken\n'` and `'gitkraken\u0000'` alike emit `org:gitkraken` — the scope that was asked for. Note this
|
|
382
|
+
* is wider than `String.trim()`, which leaves control characters in place.
|
|
336
383
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
384
|
+
* None of the three outcomes above survives the stripping, so the rule loses nothing: an all-edge value still
|
|
385
|
+
* empties, an INNER space or control character still splits (`'git\u0000kraken'` emits `git kraken`, two
|
|
386
|
+
* tokens), and a quote still alters wherever it sits.
|
|
339
387
|
*/
|
|
340
388
|
function isUsableSearchScopeName(name: string): boolean {
|
|
389
|
+
const stripped = stripSearchScopeEdges(name);
|
|
341
390
|
// eslint-disable-next-line no-control-regex
|
|
342
|
-
return
|
|
391
|
+
return stripped.length > 0 && !/["\u0000-\u001f\u007f\s]/.test(stripped);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The leading/trailing run a provider's sanitizing removes — see {@link isUsableSearchScopeName}. */
|
|
395
|
+
function stripSearchScopeEdges(value: string): string {
|
|
396
|
+
// eslint-disable-next-line no-control-regex
|
|
397
|
+
return value.replace(/^[\s\u0000-\u001f\u007f]+|[\s\u0000-\u001f\u007f]+$/g, '');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The scope names a search cannot carry as given, as the strings to name in the refusal — empty when every one is
|
|
402
|
+
* usable. See {@link isUsableSearchScopeName} for the rule and why it refuses rather than sanitizes.
|
|
403
|
+
*/
|
|
404
|
+
function unusableSearchScopeNames(org: string | undefined, repos: readonly ProviderRepoInput[] | undefined): string[] {
|
|
405
|
+
const unusable: string[] = [];
|
|
406
|
+
|
|
407
|
+
// An EMPTY org is "no org supplied" and falls through to the remaining scopes; any other unusable value WAS
|
|
408
|
+
// supplied, so it is refused rather than dropped.
|
|
409
|
+
if (org != null && org.length > 0 && !isUsableSearchScopeName(org)) {
|
|
410
|
+
unusable.push(org);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A `repo:` qualifier names a repository by its JOINED `namespace/name` path, which is the value the rule
|
|
414
|
+
// below is applied to. Both halves are read defensively, through the SAME locals the label is built from: the
|
|
415
|
+
// descriptor form is only narrowed from a union by an element-type check, so a half-built descriptor reaches
|
|
416
|
+
// here as `undefined` and must refuse rather than throw out of a facade that reports refusals as warnings —
|
|
417
|
+
// and reporting it as `undefined/a` would name a value the caller never passed.
|
|
418
|
+
for (const repo of repos ?? []) {
|
|
419
|
+
const namespace = repo.namespace ?? '';
|
|
420
|
+
const name = repo.name ?? '';
|
|
421
|
+
const path = `${namespace}/${name}`;
|
|
422
|
+
// BOTH the composite and each half, because neither sees what the other does:
|
|
423
|
+
// - the composite catches an offender the halves cannot, since an edge of a half is an INTERIOR character
|
|
424
|
+
// of the path — `{ 'git ', 'kraken' }` has two usable-looking halves and emits `repo:git /kraken`.
|
|
425
|
+
// - the halves catch a BLANK one the composite cannot, since that offender sits at a composite EDGE where
|
|
426
|
+
// stripping removes it — `' /a'` strips to `'/a'`, a perfectly spellable qualifier naming no repository.
|
|
427
|
+
// Each half is measured after the same stripping, so `' '` and `''` are one case rather than two.
|
|
428
|
+
if (
|
|
429
|
+
!isUsableSearchScopeName(path) ||
|
|
430
|
+
stripSearchScopeEdges(namespace).length === 0 ||
|
|
431
|
+
stripSearchScopeEdges(name).length === 0
|
|
432
|
+
) {
|
|
433
|
+
unusable.push(path);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return unusable;
|
|
343
438
|
}
|
|
344
439
|
|
|
345
440
|
/**
|
|
@@ -351,32 +446,39 @@ function isUsableSearchScopeName(name: string): boolean {
|
|
|
351
446
|
* warning: the two callers word it differently (whole-read vs naming the offending scope's key), and wording is
|
|
352
447
|
* the warning layer's business.
|
|
353
448
|
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
449
|
+
* `repo-ids` and `unscoped` are mutually exclusive — one requires repositories and the other requires none — so
|
|
450
|
+
* their relative order is free. `unusable-scope` is NOT: it must precede `unscoped`, so a value that was supplied
|
|
451
|
+
* but cannot be used is never reported as a missing one.
|
|
356
452
|
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* it (`toGitHubIssueSearchScopeQualifiers` emits no bare `org:`, which GitHub would reject), so an org of `' '`
|
|
360
|
-
* or `'"'` would pass a length check, emit NO scope qualifier at all, and leave a search of the entire host —
|
|
361
|
-
* measured at 52 million issues across unrelated accounts. Whitespace and quotes are exactly what a name pasted
|
|
362
|
-
* from a config or a URL degrades to, so this is reachable without anything adversarial.
|
|
453
|
+
* Every scope name is checked for naming the same scope after the provider sanitizes it, not merely for being
|
|
454
|
+
* non-empty, and that distinction is a SECURITY one rather than a nicety — see {@link isUsableSearchScopeName}.
|
|
363
455
|
*/
|
|
364
456
|
export function resolveIssueSearchScope(
|
|
365
457
|
repos: ProviderReposInput | undefined,
|
|
366
458
|
org: string | undefined,
|
|
367
459
|
criteria: IssueSearchCriteria | undefined,
|
|
368
460
|
): { rejection?: IssueSearchScopeRejection; repos?: ProviderRepoInput[] } {
|
|
461
|
+
let resolvedRepos: ProviderRepoInput[] | undefined;
|
|
462
|
+
|
|
369
463
|
if (repos?.length) {
|
|
370
464
|
// `ProviderReposInput` is a union of descriptor and id arrays; only the descriptor form is usable here.
|
|
371
|
-
if (repos.some(r => typeof r === 'string' || typeof r === 'number'))
|
|
465
|
+
if (repos.some(r => typeof r === 'string' || typeof r === 'number')) {
|
|
466
|
+
return { rejection: { reason: 'repo-ids' } };
|
|
467
|
+
}
|
|
372
468
|
|
|
373
|
-
|
|
469
|
+
resolvedRepos = repos as ProviderRepoInput[];
|
|
374
470
|
}
|
|
375
471
|
|
|
376
|
-
|
|
472
|
+
// Checked BEFORE the "is it scoped at all" rule below, so an unusable value is never reported as a missing
|
|
473
|
+
// one: it was supplied, and telling the caller to pass a scope it already passed names the wrong defect.
|
|
474
|
+
const unusable = unusableSearchScopeNames(org, resolvedRepos);
|
|
475
|
+
if (unusable.length > 0) return { rejection: { reason: 'unusable-scope', scopes: unusable } };
|
|
476
|
+
|
|
477
|
+
if (resolvedRepos != null) return { repos: resolvedRepos };
|
|
478
|
+
if (org != null && org.length > 0) return {};
|
|
377
479
|
if (criteria?.relationships?.some(r => userScopingIssueSearchRelationships.includes(r)) === true) return {};
|
|
378
480
|
|
|
379
|
-
return { rejection: 'unscoped' };
|
|
481
|
+
return { rejection: { reason: 'unscoped' } };
|
|
380
482
|
}
|
|
381
483
|
|
|
382
484
|
/**
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
otherWarning,
|
|
26
26
|
truncationWarning,
|
|
27
27
|
unsupportedIssueSearchCriteriaWarning,
|
|
28
|
+
unusableSearchScopeMessage,
|
|
28
29
|
} from './warnings.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -69,11 +70,7 @@ export async function searchIssuesPage(
|
|
|
69
70
|
criteria?: IssueSearchCriteria;
|
|
70
71
|
page?: number;
|
|
71
72
|
cursor?: string;
|
|
72
|
-
/**
|
|
73
|
-
* Page size PER RELATIONSHIP: each one is its own provider query, so a page of an N-relationship search
|
|
74
|
-
* returns up to `N × itemsPerPage` items before the url dedupe, and fewer where they overlap.
|
|
75
|
-
* `page.itemsPerPage` reports what actually came back.
|
|
76
|
-
*/
|
|
73
|
+
/** Per relationship, not per page; see {@link IntegrationManager.searchIssuesPage}. */
|
|
77
74
|
itemsPerPage?: number;
|
|
78
75
|
forceSync?: boolean;
|
|
79
76
|
connectionId?: string;
|
|
@@ -117,8 +114,18 @@ export async function searchIssuesPage(
|
|
|
117
114
|
return refused(issuesUnsupportedWarning(options.providerId, domain, options.connectionId));
|
|
118
115
|
}
|
|
119
116
|
|
|
117
|
+
const resolved = resolveIssueSearchCriteria(options.providerId, options.criteria);
|
|
118
|
+
if (resolved.rejection != null) {
|
|
119
|
+
return refused(
|
|
120
|
+
unsupportedIssueSearchCriteriaWarning(options.providerId, domain, options.connectionId, resolved.rejection),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// AFTER the criteria check, which is what reports a provider with no filtered issue search at all: that is
|
|
125
|
+
// the more fundamental refusal, and answering "your scope name is malformed" to a caller whose provider has
|
|
126
|
+
// no such search names the wrong defect. Matches the pull-request twin, which probes existence first.
|
|
120
127
|
const scope = resolveIssueSearchScope(options.repos, options.org, options.criteria);
|
|
121
|
-
switch (scope.rejection) {
|
|
128
|
+
switch (scope.rejection?.reason) {
|
|
122
129
|
case 'unscoped':
|
|
123
130
|
return refused(
|
|
124
131
|
otherWarning(
|
|
@@ -140,13 +147,16 @@ export async function searchIssuesPage(
|
|
|
140
147
|
'A filtered issue search cannot be scoped by repository id; pass repository descriptors (namespace + name) instead.',
|
|
141
148
|
),
|
|
142
149
|
);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
// A scope the query cannot spell, refused rather than sanitized — see `isUsableSearchScopeName`.
|
|
151
|
+
case 'unusable-scope':
|
|
152
|
+
return refused(
|
|
153
|
+
otherWarning(
|
|
154
|
+
options.providerId,
|
|
155
|
+
domain,
|
|
156
|
+
options.connectionId,
|
|
157
|
+
unusableSearchScopeMessage('issue search scopes', scope.rejection.scopes),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
150
160
|
}
|
|
151
161
|
|
|
152
162
|
const readPage = (cursor: string | undefined) =>
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
pullRequestSearchCapResultWarning,
|
|
25
25
|
truncationWarning,
|
|
26
26
|
unsupportedPullRequestSearchCriteriaWarning,
|
|
27
|
+
unusableSearchScopeMessage,
|
|
27
28
|
} from './warnings.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -44,6 +45,7 @@ export async function searchPullRequestsPage(
|
|
|
44
45
|
criteria?: PullRequestSearchCriteria;
|
|
45
46
|
page?: number;
|
|
46
47
|
cursor?: string;
|
|
48
|
+
/** Per relationship × state facet, not per page; see {@link IntegrationManager.searchPullRequestsPage}. */
|
|
47
49
|
itemsPerPage?: number;
|
|
48
50
|
forceSync?: boolean;
|
|
49
51
|
connectionId?: string;
|
|
@@ -100,7 +102,7 @@ export async function searchPullRequestsPage(
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
const scope = resolvePullRequestSearchScope(options.providerId, options.repos, options.org, options.criteria);
|
|
103
|
-
switch (scope.rejection) {
|
|
105
|
+
switch (scope.rejection?.reason) {
|
|
104
106
|
case 'unscoped':
|
|
105
107
|
return refused(
|
|
106
108
|
otherWarning(
|
|
@@ -125,10 +127,22 @@ export async function searchPullRequestsPage(
|
|
|
125
127
|
unsupportedPullRequestSearchCriteriaWarning(options.providerId, domain, options.connectionId, {
|
|
126
128
|
reason: 'unsupported-criteria',
|
|
127
129
|
criteria: [
|
|
128
|
-
scope.rejection === 'unsupported-repository-scope'
|
|
130
|
+
scope.rejection.reason === 'unsupported-repository-scope'
|
|
131
|
+
? 'repositoryScope'
|
|
132
|
+
: 'organizationScope',
|
|
129
133
|
],
|
|
130
134
|
}),
|
|
131
135
|
);
|
|
136
|
+
// A scope the query cannot spell, refused rather than sanitized — see `isUsableSearchScopeName`.
|
|
137
|
+
case 'unusable-scope':
|
|
138
|
+
return refused(
|
|
139
|
+
otherWarning(
|
|
140
|
+
options.providerId,
|
|
141
|
+
domain,
|
|
142
|
+
options.connectionId,
|
|
143
|
+
unusableSearchScopeMessage('pull request search scopes', scope.rejection.scopes),
|
|
144
|
+
),
|
|
145
|
+
);
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
const readPage = (cursor: string | undefined) =>
|