@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/plus/git-github/api/github.d.ts +3 -2
  3. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  4. package/dist/plus/git-github/api/github.js.map +1 -1
  5. package/dist/plus/integrations/manager.d.ts +43 -6
  6. package/dist/plus/integrations/manager.d.ts.map +1 -1
  7. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  8. package/dist/plus/integrations/reads/broaden.js +26 -14
  9. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  10. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  11. package/dist/plus/integrations/reads/counts.js +39 -15
  12. package/dist/plus/integrations/reads/counts.js.map +1 -1
  13. package/dist/plus/integrations/reads/filters.d.ts +33 -11
  14. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  15. package/dist/plus/integrations/reads/filters.js +114 -25
  16. package/dist/plus/integrations/reads/filters.js.map +1 -1
  17. package/dist/plus/integrations/reads/searchIssues.d.ts +1 -5
  18. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
  19. package/dist/plus/integrations/reads/searchIssues.js +12 -6
  20. package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
  21. package/dist/plus/integrations/reads/searchPullRequests.d.ts +1 -0
  22. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
  23. package/dist/plus/integrations/reads/searchPullRequests.js +8 -3
  24. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
  25. package/dist/plus/integrations/reads/warnings.d.ts +16 -0
  26. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  27. package/dist/plus/integrations/reads/warnings.js +18 -0
  28. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  29. package/docs/integrations.md +26 -2
  30. package/package.json +1 -1
  31. package/src/plus/git-github/api/github.ts +3 -2
  32. package/src/plus/integrations/manager.ts +43 -6
  33. package/src/plus/integrations/reads/broaden.ts +35 -16
  34. package/src/plus/integrations/reads/counts.ts +62 -15
  35. package/src/plus/integrations/reads/filters.ts +131 -29
  36. package/src/plus/integrations/reads/searchIssues.ts +23 -13
  37. package/src/plus/integrations/reads/searchPullRequests.ts +16 -2
  38. 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
- /** Repository descriptors that bound the search; ids cannot name provider search qualifiers. */
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
- /** Organization/account that bounds the search. */
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
- /** Page size per relationship × state facet; the deduped union can contain more rows. */
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 raises the default page size.
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
- /** Repositories to search. Combines with `org`; both constrain the same query. */
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
- /** Organization/account to search. Combines with `repos`. */
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
- const read = canSearchOrgIssues(org)
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
- // `push`, not `appendDedupedWarning`: every rejection message embeds the scope's own key, and duplicate
213
- // keys were already refused above, so no two of these can ever collapse deduping them would only pay
214
- // the O(n²) key comparison to prove it.
215
- warnings.push(warning);
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
- const resolved = resolveIssueSearchCriteria(providerId, scope.criteria);
326
- if (resolved.rejection != null) {
327
- return unsupportedIssueSearchCriteriaWarning(providerId, domain, connectionId, resolved.rejection);
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
- // `push`, not `appendDedupedWarning`: every rejection message embeds the scope's own key, and duplicate
439
- // keys were already refused above, so no two of these can ever collapse.
440
- warnings.push(warning);
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' ? 'repositoryScope' : 'organizationScope',
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')) return { rejection: 'repo-ids' };
152
- if (supported?.repositoryScope !== true) return { rejection: 'unsupported-repository-scope' };
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 survives the provider's sanitizing, i.e. whether it will actually constrain the query.
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. What is common to any query language is the part that matters: a value made only of
334
- * whitespace, quotes and control characters carries no name, and every sanitizer strips exactly those (quotes
335
- * because they would close their own qualifier, control characters because they cannot appear in a query at all).
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
- * Strictly weaker than any provider's own sanitizing, which is the safe direction: this can only pass a name the
338
- * provider would then narrow further, never reject one the provider would have accepted.
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 name.replace(/["\u0000-\u001f\u007f\s]/g, '').length > 0;
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
- * The two rejections are mutually exclusive — `repo-ids` requires repositories and `unscoped` requires none — so
355
- * the order they're checked in cannot change the outcome.
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
- * An org is checked for what SURVIVES SANITIZING, not merely for being non-empty, and that distinction is a
358
- * SECURITY one rather than a nicety: the provider query drops a value that sanitizes away rather than rejecting
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')) return { rejection: 'repo-ids' };
465
+ if (repos.some(r => typeof r === 'string' || typeof r === 'number')) {
466
+ return { rejection: { reason: 'repo-ids' } };
467
+ }
372
468
 
373
- return { repos: repos as ProviderRepoInput[] };
469
+ resolvedRepos = repos as ProviderRepoInput[];
374
470
  }
375
471
 
376
- if (org != null && isUsableSearchScopeName(org)) return {};
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
- const resolved = resolveIssueSearchCriteria(options.providerId, options.criteria);
146
- if (resolved.rejection != null) {
147
- return refused(
148
- unsupportedIssueSearchCriteriaWarning(options.providerId, domain, options.connectionId, resolved.rejection),
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' ? 'repositoryScope' : 'organizationScope',
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) =>