@gitkraken/core-gitlens 0.5.115 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/git/models/issue.d.ts +14 -1
  3. package/dist/git/models/issue.d.ts.map +1 -1
  4. package/dist/git/models/issue.js +5 -1
  5. package/dist/git/models/issue.js.map +1 -1
  6. package/dist/git/models/pullRequest.d.ts +8 -2
  7. package/dist/git/models/pullRequest.d.ts.map +1 -1
  8. package/dist/git/remotes/azure-devops.d.ts +11 -1
  9. package/dist/git/remotes/azure-devops.d.ts.map +1 -1
  10. package/dist/git/remotes/azure-devops.js +47 -12
  11. package/dist/git/remotes/azure-devops.js.map +1 -1
  12. package/dist/git/utils/issue.utils.d.ts.map +1 -1
  13. package/dist/git/utils/issue.utils.js +9 -0
  14. package/dist/git/utils/issue.utils.js.map +1 -1
  15. package/dist/plus/git-github/api/github.d.ts +3 -2
  16. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  17. package/dist/plus/git-github/api/github.js.map +1 -1
  18. package/dist/plus/integrations/manager.d.ts +43 -6
  19. package/dist/plus/integrations/manager.d.ts.map +1 -1
  20. package/dist/plus/integrations/providers/azure/azure.d.ts +13 -0
  21. package/dist/plus/integrations/providers/azure/azure.d.ts.map +1 -1
  22. package/dist/plus/integrations/providers/azure/azure.js +79 -16
  23. package/dist/plus/integrations/providers/azure/azure.js.map +1 -1
  24. package/dist/plus/integrations/providers/azure/models.d.ts +37 -10
  25. package/dist/plus/integrations/providers/azure/models.d.ts.map +1 -1
  26. package/dist/plus/integrations/providers/azure/models.js +118 -52
  27. package/dist/plus/integrations/providers/azure/models.js.map +1 -1
  28. package/dist/plus/integrations/providers/azureDevOps.d.ts +1 -0
  29. package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
  30. package/dist/plus/integrations/providers/azureDevOps.js +19 -7
  31. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  32. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  33. package/dist/plus/integrations/providers/models.js +15 -4
  34. package/dist/plus/integrations/providers/models.js.map +1 -1
  35. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  36. package/dist/plus/integrations/reads/broaden.js +26 -14
  37. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  38. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  39. package/dist/plus/integrations/reads/counts.js +39 -15
  40. package/dist/plus/integrations/reads/counts.js.map +1 -1
  41. package/dist/plus/integrations/reads/filters.d.ts +33 -11
  42. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  43. package/dist/plus/integrations/reads/filters.js +114 -25
  44. package/dist/plus/integrations/reads/filters.js.map +1 -1
  45. package/dist/plus/integrations/reads/resolveRepository.d.ts.map +1 -1
  46. package/dist/plus/integrations/reads/resolveRepository.js +2 -0
  47. package/dist/plus/integrations/reads/resolveRepository.js.map +1 -1
  48. package/dist/plus/integrations/reads/searchIssues.d.ts +1 -5
  49. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
  50. package/dist/plus/integrations/reads/searchIssues.js +12 -6
  51. package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
  52. package/dist/plus/integrations/reads/searchPullRequests.d.ts +1 -0
  53. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
  54. package/dist/plus/integrations/reads/searchPullRequests.js +8 -3
  55. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
  56. package/dist/plus/integrations/reads/warnings.d.ts +16 -0
  57. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  58. package/dist/plus/integrations/reads/warnings.js +18 -0
  59. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  60. package/docs/integrations.md +59 -2
  61. package/docs/kepler-read-api-parity.md +15 -12
  62. package/package.json +1 -1
  63. package/src/git/models/issue.ts +15 -0
  64. package/src/git/models/pullRequest.ts +6 -2
  65. package/src/git/remotes/azure-devops.ts +68 -14
  66. package/src/git/utils/issue.utils.ts +10 -0
  67. package/src/plus/git-github/api/github.ts +3 -2
  68. package/src/plus/integrations/manager.ts +43 -6
  69. package/src/plus/integrations/providers/azure/azure.ts +147 -16
  70. package/src/plus/integrations/providers/azure/models.ts +165 -63
  71. package/src/plus/integrations/providers/azureDevOps.ts +22 -13
  72. package/src/plus/integrations/providers/models.ts +22 -5
  73. package/src/plus/integrations/reads/broaden.ts +35 -16
  74. package/src/plus/integrations/reads/counts.ts +62 -15
  75. package/src/plus/integrations/reads/filters.ts +131 -29
  76. package/src/plus/integrations/reads/resolveRepository.ts +3 -0
  77. package/src/plus/integrations/reads/searchIssues.ts +23 -13
  78. package/src/plus/integrations/reads/searchPullRequests.ts +16 -2
  79. package/src/plus/integrations/reads/warnings.ts +19 -0
@@ -76,6 +76,17 @@ function getAzureRepositoryIdentity(repo: Pick<AzureRepositoryDescriptor, 'owner
76
76
  };
77
77
  }
78
78
 
79
+ function getAzureRepositoryApiBaseUrl(baseUrl: string, repo: Pick<AzureRepositoryDescriptor, 'virtualDirectory'>) {
80
+ if (repo.virtualDirectory == null) return baseUrl;
81
+
82
+ const segments = repo.virtualDirectory.split('/');
83
+ if (segments.some(segment => !segment || segment === '.' || segment === '..')) {
84
+ throw new Error(`Invalid Azure virtual directory '${repo.virtualDirectory}'.`);
85
+ }
86
+
87
+ return `${baseUrl.replace(/\/+$/, '')}/${segments.map(encodeURIComponent).join('/')}`;
88
+ }
89
+
79
90
  /**
80
91
  * Matches an org/project descriptor against a caller-supplied name, mirroring the facade's own
81
92
  * key/id/name comparison so `listIssuesPage({ org, project })` narrows on the same identifiers a consumer
@@ -502,7 +513,7 @@ export abstract class AzureDevOpsIntegrationBase<
502
513
  repo.owner,
503
514
  repo.name,
504
515
  rev,
505
- this.apiBaseUrl,
516
+ getAzureRepositoryApiBaseUrl(this.apiBaseUrl, repo),
506
517
  options,
507
518
  );
508
519
  }
@@ -528,7 +539,7 @@ export abstract class AzureDevOpsIntegrationBase<
528
539
  toTokenWithInfo(this.id, session),
529
540
  repo.owner,
530
541
  repo.name,
531
- { baseUrl: this.apiBaseUrl },
542
+ { baseUrl: getAzureRepositoryApiBaseUrl(this.apiBaseUrl, repo) },
532
543
  cancellation,
533
544
  );
534
545
  }
@@ -546,7 +557,7 @@ export abstract class AzureDevOpsIntegrationBase<
546
557
  repo.name,
547
558
  id,
548
559
  {
549
- baseUrl: this.apiBaseUrl,
560
+ baseUrl: getAzureRepositoryApiBaseUrl(this.apiBaseUrl, repo),
550
561
  type: type,
551
562
  },
552
563
  );
@@ -596,7 +607,7 @@ export abstract class AzureDevOpsIntegrationBase<
596
607
  repo.name,
597
608
  branch,
598
609
  {
599
- baseUrl: this.apiBaseUrl,
610
+ baseUrl: getAzureRepositoryApiBaseUrl(this.apiBaseUrl, repo),
600
611
  },
601
612
  );
602
613
  }
@@ -612,7 +623,7 @@ export abstract class AzureDevOpsIntegrationBase<
612
623
  repo.owner,
613
624
  repo.name,
614
625
  rev,
615
- this.apiBaseUrl,
626
+ getAzureRepositoryApiBaseUrl(this.apiBaseUrl, repo),
616
627
  );
617
628
  }
618
629
 
@@ -620,6 +631,7 @@ export abstract class AzureDevOpsIntegrationBase<
620
631
  owner: string;
621
632
  name: string;
622
633
  project?: string;
634
+ virtualDirectory?: string;
623
635
  connectionId?: string;
624
636
  }): Promise<ProviderRepository | undefined> {
625
637
  const identity = getAzureRepositoryIdentity(repo);
@@ -631,13 +643,10 @@ export abstract class AzureDevOpsIntegrationBase<
631
643
  if (session == null) return undefined;
632
644
 
633
645
  const { tokenWithInfo, options } = this.getApiOptions(session);
634
- return api.getRepo(
635
- tokenWithInfo,
636
- identity.resourceName,
637
- identity.repositoryName,
638
- identity.projectName,
639
- options,
640
- );
646
+ return api.getRepo(tokenWithInfo, identity.resourceName, identity.repositoryName, identity.projectName, {
647
+ ...options,
648
+ baseUrl: getAzureRepositoryApiBaseUrl(this.apiBaseUrl, repo),
649
+ });
641
650
  }
642
651
 
643
652
  protected override async getProviderRepositoryMetadata(
@@ -650,7 +659,7 @@ export abstract class AzureDevOpsIntegrationBase<
650
659
  toTokenWithInfo(this.id, session),
651
660
  repo.owner,
652
661
  repo.name,
653
- { baseUrl: this.apiBaseUrl },
662
+ { baseUrl: getAzureRepositoryApiBaseUrl(this.apiBaseUrl, repo) },
654
663
  cancellation,
655
664
  );
656
665
  }
@@ -47,7 +47,7 @@ import { EntityIdentifierUtils } from '@gitkraken/provider-apis/entity-identifie
47
47
  import { GitProviderUtils } from '@gitkraken/provider-apis/provider-utils';
48
48
  import { githubSearchResultLimit } from '../../git-github/api/config.js';
49
49
  import type { Account as UserAccount } from '../../../git/models/author.js';
50
- import type { IssueProject, IssueShape, IssueStateFilter } from '../../../git/models/issue.js';
50
+ import type { IssueProject, IssueProviderState, IssueShape, IssueStateFilter } from '../../../git/models/issue.js';
51
51
  import { Issue, RepositoryAccessLevel } from '../../../git/models/issue.js';
52
52
  import type {
53
53
  PullRequestRef,
@@ -1229,6 +1229,20 @@ function toIssueIdentifier(value: string | number): string {
1229
1229
  return String(value);
1230
1230
  }
1231
1231
 
1232
+ function toIssueProviderState(
1233
+ state: ProviderIssue['state'],
1234
+ reliableStateCategory = true,
1235
+ ): IssueProviderState | undefined {
1236
+ return state == null
1237
+ ? undefined
1238
+ : {
1239
+ id: state.id,
1240
+ name: state.name,
1241
+ color: state.color ?? undefined,
1242
+ category: reliableStateCategory ? state.category : undefined,
1243
+ };
1244
+ }
1245
+
1232
1246
  export function toIssueShape(
1233
1247
  issue: ProviderIssue,
1234
1248
  provider: ProviderReference,
@@ -1242,10 +1256,9 @@ export function toIssueShape(
1242
1256
 
1243
1257
  // Jira SDK results derive this category from a localized display name and default unknown names to DONE.
1244
1258
  // Only the direct point read opts in because it maps Jira's stable status-category key itself.
1245
- const closed =
1246
- issue.closedDate != null ||
1247
- (issue.state?.category === 'DONE' &&
1248
- (provider.id !== IssuesCloudHostIntegrationId.Jira || options?.reliableStateCategory === true));
1259
+ const reliableStateCategory =
1260
+ provider.id !== IssuesCloudHostIntegrationId.Jira || options?.reliableStateCategory === true;
1261
+ const closed = issue.closedDate != null || (issue.state?.category === 'DONE' && reliableStateCategory);
1249
1262
 
1250
1263
  return {
1251
1264
  type: 'issue',
@@ -1267,6 +1280,7 @@ export function toIssueShape(
1267
1280
  closedDate: issue.closedDate ?? undefined,
1268
1281
  closed: closed,
1269
1282
  state: closed ? 'closed' : 'opened',
1283
+ providerState: toIssueProviderState(issue.state, reliableStateCategory),
1270
1284
  author: {
1271
1285
  id: issue.author?.id ?? '',
1272
1286
  // An absent name stays absent, matching {@link fromProviderAccount}; see `IssueMember.name`.
@@ -1302,6 +1316,7 @@ export function toIssueShape(
1302
1316
  commentsCount: issue.commentCount ?? undefined,
1303
1317
  thumbsUpCount: issue.upvoteCount ?? undefined,
1304
1318
  body: issue.description ?? undefined,
1319
+ bodyFormat: provider.id === IssuesCloudHostIntegrationId.Jira ? 'jira-wiki' : undefined,
1305
1320
  issueType: issue.type ?? undefined,
1306
1321
  };
1307
1322
  }
@@ -1741,6 +1756,8 @@ export function fromProviderIssue(
1741
1756
  : undefined,
1742
1757
  identifier,
1743
1758
  issue.type ?? undefined,
1759
+ toIssueProviderState(issue.state, integration.id !== IssuesCloudHostIntegrationId.Jira),
1760
+ integration.id === IssuesCloudHostIntegrationId.Jira ? 'jira-wiki' : undefined,
1744
1761
  );
1745
1762
  }
1746
1763
 
@@ -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
  /**
@@ -96,6 +96,8 @@ export async function resolveRepository(
96
96
  const name = provider.repoName;
97
97
  if (owner == null || name == null) return result('invalid-remote-url');
98
98
 
99
+ const providerRepo = provider.repoDesc;
100
+
99
101
  // On a self-managed host, resolve only against a TRUSTED host: the pinned connection's configured
100
102
  // domain, the explicit `domain`, or — when neither was supplied — a configured host matching the
101
103
  // remote's. That last case keeps `remoteUrl` (repository-supplied) out of the trusted path: it selects
@@ -164,6 +166,7 @@ export async function resolveRepository(
164
166
 
165
167
  try {
166
168
  const repo = await integration.getRepoInfo({
169
+ ...providerRepo,
167
170
  owner: owner,
168
171
  name: name,
169
172
  project: project,