@gitkraken/core-gitlens 0.5.114 → 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 (162) hide show
  1. package/CHANGELOG.md +38 -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 +37 -37
  5. package/dist/plus/git-github/api/github.js.map +1 -1
  6. package/dist/plus/integrations/collectionMetadata.d.ts +2 -0
  7. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  8. package/dist/plus/integrations/collectionMetadata.js +17 -1
  9. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  10. package/dist/plus/integrations/context.d.ts +4 -1
  11. package/dist/plus/integrations/context.d.ts.map +1 -1
  12. package/dist/plus/integrations/index.d.ts +1 -0
  13. package/dist/plus/integrations/index.d.ts.map +1 -1
  14. package/dist/plus/integrations/integrationService.d.ts +9 -1
  15. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  16. package/dist/plus/integrations/integrationService.js +8 -4
  17. package/dist/plus/integrations/integrationService.js.map +1 -1
  18. package/dist/plus/integrations/manager.d.ts +65 -6
  19. package/dist/plus/integrations/manager.d.ts.map +1 -1
  20. package/dist/plus/integrations/models/gitHostIntegration.d.ts +22 -26
  21. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  22. package/dist/plus/integrations/models/gitHostIntegration.js +92 -47
  23. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  24. package/dist/plus/integrations/models/integration.d.ts +9 -0
  25. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  26. package/dist/plus/integrations/models/integration.js +52 -19
  27. package/dist/plus/integrations/models/integration.js.map +1 -1
  28. package/dist/plus/integrations/models/issueCache.d.ts +16 -0
  29. package/dist/plus/integrations/models/issueCache.d.ts.map +1 -0
  30. package/dist/plus/integrations/models/issueCache.js +9 -0
  31. package/dist/plus/integrations/models/issueCache.js.map +1 -0
  32. package/dist/plus/integrations/models/issueReads.d.ts +13 -0
  33. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -1
  34. package/dist/plus/integrations/models/issuesIntegration.d.ts +11 -13
  35. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  36. package/dist/plus/integrations/models/issuesIntegration.js +15 -1
  37. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  38. package/dist/plus/integrations/providers/azureDevOps.d.ts +3 -5
  39. package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
  40. package/dist/plus/integrations/providers/azureDevOps.js +9 -8
  41. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  42. package/dist/plus/integrations/providers/bitbucket-server.d.ts +3 -5
  43. package/dist/plus/integrations/providers/bitbucket-server.d.ts.map +1 -1
  44. package/dist/plus/integrations/providers/bitbucket-server.js +2 -2
  45. package/dist/plus/integrations/providers/bitbucket-server.js.map +1 -1
  46. package/dist/plus/integrations/providers/bitbucket.d.ts +3 -5
  47. package/dist/plus/integrations/providers/bitbucket.d.ts.map +1 -1
  48. package/dist/plus/integrations/providers/bitbucket.js +2 -1
  49. package/dist/plus/integrations/providers/bitbucket.js.map +1 -1
  50. package/dist/plus/integrations/providers/github.d.ts +3 -5
  51. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  52. package/dist/plus/integrations/providers/github.js +3 -3
  53. package/dist/plus/integrations/providers/github.js.map +1 -1
  54. package/dist/plus/integrations/providers/gitlab.d.ts +3 -5
  55. package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
  56. package/dist/plus/integrations/providers/gitlab.js +2 -2
  57. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  58. package/dist/plus/integrations/providers/jira.d.ts +3 -7
  59. package/dist/plus/integrations/providers/jira.d.ts.map +1 -1
  60. package/dist/plus/integrations/providers/jira.js +35 -22
  61. package/dist/plus/integrations/providers/jira.js.map +1 -1
  62. package/dist/plus/integrations/providers/jiraIssueByKey.d.ts +7 -0
  63. package/dist/plus/integrations/providers/jiraIssueByKey.d.ts.map +1 -0
  64. package/dist/plus/integrations/providers/jiraIssueByKey.js +102 -0
  65. package/dist/plus/integrations/providers/jiraIssueByKey.js.map +1 -0
  66. package/dist/plus/integrations/providers/linear.d.ts +3 -7
  67. package/dist/plus/integrations/providers/linear.d.ts.map +1 -1
  68. package/dist/plus/integrations/providers/linear.js +23 -22
  69. package/dist/plus/integrations/providers/linear.js.map +1 -1
  70. package/dist/plus/integrations/providers/models.d.ts +3 -1
  71. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  72. package/dist/plus/integrations/providers/models.js +10 -4
  73. package/dist/plus/integrations/providers/models.js.map +1 -1
  74. package/dist/plus/integrations/providers/providerErrors.d.ts +7 -0
  75. package/dist/plus/integrations/providers/providerErrors.d.ts.map +1 -0
  76. package/dist/plus/integrations/providers/providerErrors.js +91 -0
  77. package/dist/plus/integrations/providers/providerErrors.js.map +1 -0
  78. package/dist/plus/integrations/providers/providersApi.d.ts +3 -2
  79. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  80. package/dist/plus/integrations/providers/providersApi.js +18 -52
  81. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  82. package/dist/plus/integrations/providers/trello.d.ts +2 -7
  83. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  84. package/dist/plus/integrations/providers/trello.js +3 -1
  85. package/dist/plus/integrations/providers/trello.js.map +1 -1
  86. package/dist/plus/integrations/providers/utils/providerPaging.d.ts +2 -0
  87. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  88. package/dist/plus/integrations/providers/utils/providerPaging.js +12 -7
  89. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  90. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  91. package/dist/plus/integrations/reads/broaden.js +26 -14
  92. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  93. package/dist/plus/integrations/reads/counts.d.ts.map +1 -1
  94. package/dist/plus/integrations/reads/counts.js +39 -15
  95. package/dist/plus/integrations/reads/counts.js.map +1 -1
  96. package/dist/plus/integrations/reads/filters.d.ts +33 -11
  97. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  98. package/dist/plus/integrations/reads/filters.js +114 -25
  99. package/dist/plus/integrations/reads/filters.js.map +1 -1
  100. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  101. package/dist/plus/integrations/reads/issueTracker.js +23 -17
  102. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  103. package/dist/plus/integrations/reads/searchIssues.d.ts +1 -5
  104. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -1
  105. package/dist/plus/integrations/reads/searchIssues.js +12 -6
  106. package/dist/plus/integrations/reads/searchIssues.js.map +1 -1
  107. package/dist/plus/integrations/reads/searchPullRequests.d.ts +1 -0
  108. package/dist/plus/integrations/reads/searchPullRequests.d.ts.map +1 -1
  109. package/dist/plus/integrations/reads/searchPullRequests.js +8 -3
  110. package/dist/plus/integrations/reads/searchPullRequests.js.map +1 -1
  111. package/dist/plus/integrations/reads/trackerIssue.d.ts +21 -0
  112. package/dist/plus/integrations/reads/trackerIssue.d.ts.map +1 -0
  113. package/dist/plus/integrations/reads/trackerIssue.js +51 -0
  114. package/dist/plus/integrations/reads/trackerIssue.js.map +1 -0
  115. package/dist/plus/integrations/reads/warnings.d.ts +18 -15
  116. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  117. package/dist/plus/integrations/reads/warnings.js +37 -1
  118. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  119. package/dist/plus/integrations/results.d.ts +4 -20
  120. package/dist/plus/integrations/results.d.ts.map +1 -1
  121. package/dist/plus/integrations/results.js.map +1 -1
  122. package/dist/utils/promise.d.ts +2 -0
  123. package/dist/utils/promise.d.ts.map +1 -1
  124. package/dist/utils/promise.js +11 -0
  125. package/dist/utils/promise.js.map +1 -1
  126. package/docs/integrations.md +63 -28
  127. package/docs/kepler-read-api-parity.md +13 -0
  128. package/package.json +2 -2
  129. package/src/plus/git-github/api/github.ts +54 -60
  130. package/src/plus/integrations/collectionMetadata.ts +23 -1
  131. package/src/plus/integrations/context.ts +1 -1
  132. package/src/plus/integrations/index.ts +1 -0
  133. package/src/plus/integrations/integrationService.ts +14 -4
  134. package/src/plus/integrations/manager.ts +65 -6
  135. package/src/plus/integrations/models/gitHostIntegration.ts +117 -129
  136. package/src/plus/integrations/models/integration.ts +56 -22
  137. package/src/plus/integrations/models/issueCache.ts +29 -0
  138. package/src/plus/integrations/models/issueReads.ts +15 -0
  139. package/src/plus/integrations/models/issuesIntegration.ts +38 -8
  140. package/src/plus/integrations/providers/azureDevOps.ts +16 -13
  141. package/src/plus/integrations/providers/bitbucket-server.ts +4 -6
  142. package/src/plus/integrations/providers/bitbucket.ts +3 -4
  143. package/src/plus/integrations/providers/github.ts +5 -7
  144. package/src/plus/integrations/providers/gitlab.ts +4 -6
  145. package/src/plus/integrations/providers/jira.ts +52 -26
  146. package/src/plus/integrations/providers/jiraIssueByKey.ts +155 -0
  147. package/src/plus/integrations/providers/linear.ts +38 -25
  148. package/src/plus/integrations/providers/models.ts +17 -5
  149. package/src/plus/integrations/providers/providerErrors.ts +115 -0
  150. package/src/plus/integrations/providers/providersApi.ts +26 -68
  151. package/src/plus/integrations/providers/trello.ts +5 -4
  152. package/src/plus/integrations/providers/utils/providerPaging.ts +14 -7
  153. package/src/plus/integrations/reads/broaden.ts +35 -16
  154. package/src/plus/integrations/reads/counts.ts +62 -15
  155. package/src/plus/integrations/reads/filters.ts +131 -29
  156. package/src/plus/integrations/reads/issueTracker.ts +26 -24
  157. package/src/plus/integrations/reads/searchIssues.ts +23 -13
  158. package/src/plus/integrations/reads/searchPullRequests.ts +16 -2
  159. package/src/plus/integrations/reads/trackerIssue.ts +105 -0
  160. package/src/plus/integrations/reads/warnings.ts +42 -17
  161. package/src/plus/integrations/results.ts +4 -20
  162. package/src/utils/promise.ts +15 -0
@@ -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
  /**
@@ -2,7 +2,7 @@ import type { CollectionMetadata } from '@gitkraken/provider-apis';
2
2
  import type { IssueShape, IssueSorting } from '../../../git/models/issue.js';
3
3
  import type { ResourceDescriptor } from '../../../git/models/resourceDescriptor.js';
4
4
  import { mapBounded } from '../../../utils/promise.js';
5
- import { mergeAssessmentInto } from '../collectionMetadata.js';
5
+ import { assessCollectionMetadata, mergeAssessmentInto } from '../collectionMetadata.js';
6
6
  import type { IntegrationIds } from '../constants.js';
7
7
  import { providerFanOutConcurrency } from '../constants.js';
8
8
  import { isIssuesIntegration } from '../models/issuesIntegration.js';
@@ -452,10 +452,7 @@ export async function listIssueTrackerIssuesPage(
452
452
  // Partial project discovery means some projects' issues are missing from this page; propagate it so the
453
453
  // page reports fetchFailed even when every discovered project's own read succeeded.
454
454
  let fetchFailed = projectDiscoveryFailed || accountLookupFailed;
455
- // A project whose internal page-drain hit its backstop (Jira/Linear cap at maxPagesPerRequest) reports
456
- // `truncated`; surface it as `page.truncated` so a windowed read isn't published as having drained each
457
- // project completely.
458
- let projectTruncated = projectDiscoveryTruncated;
455
+ let truncationRecovery: 'narrow-scope' | 'none' | undefined = projectDiscoveryTruncated ? 'none' : undefined;
459
456
  let drainMetadata: CollectionMetadata | undefined;
460
457
  for (const { project, value: result, warning } of perProject) {
461
458
  const key = projectKey(project);
@@ -465,7 +462,7 @@ export async function listIssueTrackerIssuesPage(
465
462
  // single failing token repeats verbatim once per project in the window.
466
463
  appendDedupedWarning(warnings, warning);
467
464
  fetchFailed = true;
468
- projectTruncated = true;
465
+ truncationRecovery = 'none';
469
466
  }
470
467
  // A thrown/unsupported read (e.g. Linear not-implemented) surfaces as a warning with no value;
471
468
  // mark the aggregate as fetchFailed so an empty result isn't mistaken for "no issues".
@@ -475,7 +472,8 @@ export async function listIssueTrackerIssuesPage(
475
472
  if (result != null) {
476
473
  items.push(...result.values);
477
474
  if (result.truncated) {
478
- projectTruncated = true;
475
+ const recovery = result.recovery;
476
+ truncationRecovery = truncationRecovery == null || truncationRecovery === recovery ? recovery : 'none';
479
477
  }
480
478
  if (result.metadata != null) {
481
479
  drainMetadata = mergeCollectionMetadata(drainMetadata, result.metadata);
@@ -497,29 +495,33 @@ export async function listIssueTrackerIssuesPage(
497
495
  // publishes. A no-op when nothing merged, since the tracker already ordered that single run.
498
496
  const orderedItems = ordering.order(items);
499
497
 
500
- const drainAssessment = mergeAssessmentInto(
501
- warnings,
502
- options.providerId,
503
- domain,
504
- options.connectionId,
505
- drainMetadata,
506
- );
498
+ const projectReadReportedTruncation = truncationRecovery != null;
499
+ const drainAssessment = assessCollectionMetadata(options.providerId, domain, options.connectionId, drainMetadata);
500
+ for (const warning of drainAssessment.warnings) {
501
+ // A project result already supplies the recovery semantics. Replace metadata's unstructured fallback with
502
+ // the structured warning below, while retaining specific failures and omissions.
503
+ if (projectReadReportedTruncation && !drainAssessment.fetchFailed && warning.omission == null) continue;
504
+
505
+ appendDedupedWarning(warnings, warning);
506
+ }
507
507
  fetchFailed = fetchFailed || drainAssessment.fetchFailed;
508
- projectTruncated = projectTruncated || drainAssessment.truncated;
508
+ if (drainAssessment.truncated) {
509
+ truncationRecovery = 'none';
510
+ }
509
511
 
510
- // A per-project read that returned data but couldn't confirm completeness (e.g. Trello's provider-native
511
- // cap) sets `truncated` without a structured failure. Add one provider-neutral incompleteness warning so
512
- // the caller sees the truncation, but only when no warning already explains it (avoid duplicate noise).
513
- if (projectTruncated && warnings.length === 0) {
514
- warnings.push(
512
+ const projectTruncated = truncationRecovery != null;
513
+ const scopeTooLarge = !fetchFailed && truncationRecovery === 'narrow-scope';
514
+ if (scopeTooLarge || (!fetchFailed && projectTruncated && !warnings.some(warning => warning.omission != null))) {
515
+ appendDedupedWarning(
516
+ warnings,
515
517
  incompleteReadWarning(
516
518
  options.providerId,
517
519
  domain,
518
520
  options.connectionId,
519
- 'Some issues were omitted; the provider returned an incomplete result.',
520
- // `exhausted`, not `page-budget`: the per-project drain's backstop is an internal constant
521
- // (`maxPagesPerRequest`), not an option this read exposes, so no caller can raise it.
522
- fetchFailed ? 'interrupted' : 'exhausted',
521
+ scopeTooLarge
522
+ ? 'Some projects hold more issues than one read can return; narrow the scope to read the rest.'
523
+ : 'Some issues were omitted; the provider returned an incomplete result.',
524
+ scopeTooLarge ? 'scope-too-large' : 'exhausted',
523
525
  ),
524
526
  );
525
527
  }
@@ -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) =>
@@ -0,0 +1,105 @@
1
+ import type { IssueShape } from '../../../git/models/issue.js';
2
+ import type { IntegrationIds } from '../constants.js';
3
+ import { IssuesCloudHostIntegrationId } from '../constants.js';
4
+ import { isIssuesIntegration } from '../models/issuesIntegration.js';
5
+ import type { ProviderResult, ProviderWarning } from '../results.js';
6
+ import { isIssuesHostIntegrationId } from '../utils/integration.utils.js';
7
+ import type { ProviderReadContext } from './context.js';
8
+ import { runCaptured } from './drains.js';
9
+ import { issueTrackerOnlySurfaceWarning, otherWarning } from './warnings.js';
10
+
11
+ export interface TrackerIssueResult {
12
+ key: string;
13
+ /**
14
+ * The resolved issue, or `undefined` when it provably does not exist or is not visible to this connection.
15
+ * A failed read returns no item and sets `fetchFailed`.
16
+ */
17
+ issue?: IssueShape;
18
+ }
19
+
20
+ /** Issue trackers are cloud-only, so this read takes no `domain`. */
21
+ export async function getTrackerIssue(
22
+ ctx: ProviderReadContext,
23
+ options: {
24
+ providerId: IntegrationIds;
25
+ resourceId: string;
26
+ resourceUrl?: string;
27
+ key: string;
28
+ connectionId?: string;
29
+ },
30
+ ): Promise<ProviderResult<TrackerIssueResult>> {
31
+ const refused = (warning: ProviderWarning): ProviderResult<TrackerIssueResult> => ({
32
+ items: [],
33
+ warnings: [warning],
34
+ fetchFailed: true,
35
+ });
36
+ const surface = 'Issue resolution by key';
37
+
38
+ if (!isIssuesHostIntegrationId(options.providerId)) {
39
+ return refused(issueTrackerOnlySurfaceWarning(options.providerId, options.connectionId, surface));
40
+ }
41
+
42
+ if (options.resourceId.trim().length === 0) {
43
+ return refused(
44
+ otherWarning(options.providerId, undefined, options.connectionId, `${surface} requires a resource id.`),
45
+ );
46
+ }
47
+
48
+ if (options.key.trim().length === 0) {
49
+ return refused(
50
+ otherWarning(options.providerId, undefined, options.connectionId, `${surface} requires an issue key.`),
51
+ );
52
+ }
53
+
54
+ const resourceUrl = options.resourceUrl?.trim() || undefined;
55
+ if (options.providerId === IssuesCloudHostIntegrationId.Jira && resourceUrl == null) {
56
+ return refused(
57
+ otherWarning(
58
+ options.providerId,
59
+ undefined,
60
+ options.connectionId,
61
+ `${surface} requires the Jira resource URL so the result contains a browser link without resource discovery.`,
62
+ ),
63
+ );
64
+ }
65
+
66
+ const integration = await ctx.getIntegrationForRead(options.providerId, options.connectionId);
67
+ if (integration == null) {
68
+ const early = ctx.earlyReturnConnectionWarnings(options.providerId, options.connectionId);
69
+ return { items: [], warnings: early.warnings, fetchFailed: early.fetchFailed || undefined };
70
+ }
71
+ if (!isIssuesIntegration(integration)) {
72
+ return refused(issueTrackerOnlySurfaceWarning(options.providerId, options.connectionId, surface));
73
+ }
74
+ if (!integration.supportsIssueLookupByResourceId) {
75
+ return refused(
76
+ otherWarning(
77
+ options.providerId,
78
+ undefined,
79
+ options.connectionId,
80
+ `${surface} is not supported by '${options.providerId}'; its single-issue read cannot prove an absence, so a miss would not be safe to cache.`,
81
+ ),
82
+ );
83
+ }
84
+
85
+ const domain = ctx.domainForRead(integration, options.providerId, options.connectionId);
86
+ const issue = await runCaptured(
87
+ options.providerId,
88
+ domain,
89
+ options.connectionId,
90
+ () =>
91
+ integration.getIssueByResourceIdResult(options.resourceId, options.key, {
92
+ connectionId: options.connectionId,
93
+ resourceUrl: resourceUrl,
94
+ }),
95
+ { warnOnMissingSession: true },
96
+ );
97
+ if (issue.warning != null) {
98
+ return refused(issue.warning);
99
+ }
100
+
101
+ return {
102
+ items: [{ key: options.key, ...(issue.value != null ? { issue: issue.value } : {}) }],
103
+ warnings: [],
104
+ };
105
+ }
@@ -8,7 +8,7 @@ import type {
8
8
  PullRequestSorting,
9
9
  } from '../providerFilters.js';
10
10
  import { providersMetadata } from '../providers/models.js';
11
- import type { ProviderWarning } from '../results.js';
11
+ import type { ProviderWarning, ProviderWarningOmissionRecovery } from '../results.js';
12
12
  import type { IssueSearchCriteriaRejection, PullRequestSearchCriteriaRejection } from './filters.js';
13
13
  import type { UnmergeableIssueSort, UnsupportedIssueSortRejection } from './ordering.js';
14
14
 
@@ -101,21 +101,23 @@ export function issueTrackerOnlySurfaceWarning(
101
101
  );
102
102
  }
103
103
 
104
- /**
105
- * Why a read the facade drove itself returned less than everything. The two questions a consumer cannot
106
- * answer from `truncated` or from the message, kept together because they are decided together:
107
- *
108
- * - `interrupted`: the read did NOT succeed — the session went away, a later page failed. It leaves an unread
109
- * tail like the others, but it is a failure: it sets `fetchFailed`, and a retry is exactly the right move.
110
- * Gets no omission at all, since the omission asserts the opposite.
111
- * - `page-budget`: the drain stopped at its own `maxPages`, with a usable cursor still in hand. The items ARE
112
- * reachable and were simply not fetched, so re-running with a higher budget returns them.
113
- * - `exhausted`: everything else that succeeded and came back short — the provider capped the page it served,
114
- * advertised another page without a usable cursor (or with one it had already handed out), or a read could
115
- * not confirm it had drained everything. The default of the three: choose it whenever a raisable budget is
116
- * not demonstrably what stopped the read, so a consumer is never offered a fetch that cannot deliver.
117
- */
118
- export type IncompleteReadCause = 'interrupted' | 'page-budget' | 'exhausted';
104
+ /** Why a facade-driven read returned fewer results than requested. */
105
+ export type IncompleteReadCause = 'interrupted' | 'page-budget' | 'exhausted' | 'scope-too-large';
106
+
107
+ function recoveryForIncompleteReadCause(cause: IncompleteReadCause): ProviderWarningOmissionRecovery {
108
+ switch (cause) {
109
+ case 'page-budget':
110
+ return 'page-budget';
111
+ case 'scope-too-large':
112
+ return 'narrow-scope';
113
+ case 'interrupted':
114
+ case 'exhausted':
115
+ return 'none';
116
+ default:
117
+ cause satisfies never;
118
+ return 'none';
119
+ }
120
+ }
119
121
 
120
122
  /**
121
123
  * Builds the warning for a read that returned less than everything, carrying the two facts a consumer cannot
@@ -143,7 +145,7 @@ export function incompleteReadWarning(
143
145
  ...warning,
144
146
  omission: {
145
147
  kind: 'pagination-incomplete',
146
- recovery: cause === 'page-budget' ? 'page-budget' : 'none',
148
+ recovery: recoveryForIncompleteReadCause(cause),
147
149
  },
148
150
  };
149
151
  }
@@ -192,6 +194,10 @@ function truncationMessage(id: IntegrationIds, readKind: TruncatedReadKind, caus
192
194
  return `${readKind} read for '${id}' stopped at its page budget; more results can be read by raising it.`;
193
195
  case 'exhausted':
194
196
  return `${readKind} read for '${id}' was truncated and cannot be continued; results may be incomplete.`;
197
+ case 'scope-too-large':
198
+ // Says what is true — the scope is larger than a read of it — rather than blaming the provider for a
199
+ // ceiling that is ours, and names the remedy that exists, since no retry or budget has one.
200
+ return `${readKind} read for '${id}' covers more than one read can return; narrow the scope to read the rest.`;
195
201
  }
196
202
  // No `default`: `IncompleteReadCause` is declared in this file, so `noImplicitReturns` already fails the
197
203
  // build here if a cause is added without its own wording. (`collectionOmissionMessage`'s `satisfies never`
@@ -255,6 +261,25 @@ export function unsupportedFiltersWarning(
255
261
  );
256
262
  }
257
263
 
264
+ /**
265
+ * The one sentence every `unusable-scope` refusal uses — the five surfaces that validate a search scope (both
266
+ * searches, both count probes, and issue broadening) differ only in the noun and in whether they name a count
267
+ * key, so the reason a scope is unusable is written once. `prefix` is what the count probes put in front of it
268
+ * to name their key.
269
+ *
270
+ * NAMES the offending values, because which failure mode applied is not deducible from the value: a quote makes
271
+ * `git"kraken` a request for the real and different org `gitkraken`, a space makes `my org` a search of `my`
272
+ * filtered by the text `org`, and a value of only quotes or whitespace leaves no scope at all. The caller is the
273
+ * only layer that knows which scope it meant.
274
+ *
275
+ * Says how to fix it rather than what was stripped: a consumer cannot mirror this rule (unlike free-text
276
+ * sanitizing, whose rules are published for exactly that), so "pass the scope as the provider names it" is the
277
+ * actionable half.
278
+ */
279
+ export function unusableSearchScopeMessage(noun: string, scopes: readonly string[], prefix?: string): string {
280
+ return `${prefix != null ? `${prefix}: ` : ''}The requested ${noun} (${scopes.map(s => JSON.stringify(s)).join(', ')}) cannot be used as given: a scope name cannot contain quotes, or an inner space or control character, which a query would drop or read as a second qualifier — searching a different scope, or none. Pass the scope exactly as the provider names it.`;
281
+ }
282
+
258
283
  /** Warning for a filtered pull-request search the provider cannot run as requested. */
259
284
  export function unsupportedPullRequestSearchCriteriaWarning(
260
285
  id: IntegrationIds,