@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
@@ -20,15 +20,8 @@ import {
20
20
  GitSelfManagedHostIntegrationId,
21
21
  IssuesCloudHostIntegrationId,
22
22
  } from '../constants.js';
23
- import {
24
- AuthenticationError,
25
- AuthenticationErrorReason,
26
- isRateLimitResponse,
27
- RequestClientError,
28
- RequestNotFoundError,
29
- toError,
30
- toRateLimitError,
31
- } from '../errors.js';
23
+ import { RequestNotFoundError, toError } from '../errors.js';
24
+ import { requestJiraIssueByKey } from './jiraIssueByKey.js';
32
25
  import type {
33
26
  GetIssueFn,
34
27
  GetIssuesForReposFn,
@@ -69,6 +62,7 @@ import type {
69
62
  PullRequestFilter,
70
63
  } from './models.js';
71
64
  import { isRepoIdsInput, providersMetadata } from './models.js';
65
+ import { getProviderResponseBodyMessage, isProviderIssueNotFoundError, throwProviderError } from './providerErrors.js';
72
66
  import {
73
67
  collectProviderPagedResult,
74
68
  mergeCollectionMetadata,
@@ -88,7 +82,6 @@ const createProviderApis: ProviderApisFactory =
88
82
  // `Repository <x> not found`. Anchoring to that shape (rather than a loose `not found` substring) keeps
89
83
  // the message fallbacks from misclassifying unrelated GraphQL/transport failures as a confident negative.
90
84
  const repoNotFoundMessage = /^Repository .+ not found$/i;
91
-
92
85
  // Duck-typed rather than `ex instanceof GraphQLErrors`, because importing the class as a value makes this
93
86
  // module unloadable from plain Node ESM consumers: `@gitkraken/provider-apis` is CommonJS and declares its
94
87
  // exports through getters, which Node's `cjs-module-lexer` cannot see, so the only named exports it can
@@ -562,43 +555,13 @@ export class ProvidersApi {
562
555
  }
563
556
 
564
557
  private handleProviderError<T>(tokenWithInfo: TokenWithInfo, error: any): T {
565
- const { accessToken: token, ...tokenInfo } = tokenWithInfo;
566
558
  const providerId = tokenWithInfo.providerId;
567
559
  const provider = this.providers[providerId];
568
560
  if (provider == null) {
569
561
  throw new Error(`Provider with id ${providerId} not registered`);
570
562
  }
571
563
 
572
- if (error?.response?.status != null) {
573
- const status: number = error.response.status;
574
- switch (status) {
575
- case 404: // Not found
576
- case 410: // Gone
577
- case 422: // Unprocessable Entity
578
- throw new RequestNotFoundError(error);
579
- case 429: // Too Many Requests
580
- throw toRateLimitError(error, token);
581
- case 401: // Unauthorized
582
- case 403: // Forbidden
583
- // A throttled request arrives on both statuses depending on the host (see
584
- // `isRateLimitResponse`); 403 previously skipped the check, so every provider going through the
585
- // SDK reported a rate limit as `auth` and asked the user to reconnect a healthy account.
586
- if (isRateLimitResponse({ status: status, message: error.message })) {
587
- throw toRateLimitError(error, token);
588
- }
589
- throw new AuthenticationError(
590
- tokenInfo,
591
- status === 401 ? AuthenticationErrorReason.Unauthorized : AuthenticationErrorReason.Forbidden,
592
- error,
593
- );
594
- default:
595
- if (status >= 400 && status < 500) {
596
- throw new RequestClientError(error);
597
- }
598
- }
599
- }
600
-
601
- throw error;
564
+ return throwProviderError(tokenWithInfo, error);
602
565
  }
603
566
 
604
567
  async getPagedResult<T>(
@@ -878,7 +841,7 @@ export class ProvidersApi {
878
841
  /** See {@link GetIssuesOptions.sort}. Linear expresses `created`/`updated`, descending only. */
879
842
  sort?: IssueSorting;
880
843
  },
881
- ): Promise<PagedResult<ProviderIssue>> {
844
+ ): Promise<ProviderApiPagedResult<ProviderIssue>> {
882
845
  const { provider, tokenWithInfo } = await this.ensureProviderTokenAndFunction(
883
846
  tokenOptInfo,
884
847
  'getLinearIssuesFn',
@@ -1522,7 +1485,7 @@ export class ProvidersApi {
1522
1485
  tokenOptInfo: TokenOptInfo,
1523
1486
  reposOrIds: ProviderReposInput,
1524
1487
  options?: GetIssuesOptions & { isPAT?: boolean; baseUrl?: string },
1525
- ): Promise<PagedResult<ProviderIssue>> {
1488
+ ): Promise<ProviderApiPagedResult<ProviderIssue>> {
1526
1489
  const { provider, tokenWithInfo } = await this.ensureProviderTokenAndFunction(
1527
1490
  tokenOptInfo,
1528
1491
  'getIssuesForReposFn',
@@ -1837,6 +1800,24 @@ export class ProvidersApi {
1837
1800
  );
1838
1801
  }
1839
1802
 
1803
+ async getJiraIssueByKey(
1804
+ tokenOptInfo: TokenWithInfo<IssuesCloudHostIntegrationId.Jira>,
1805
+ resourceId: string,
1806
+ resourceUrl: string,
1807
+ key: string,
1808
+ ): Promise<ProviderIssue | undefined> {
1809
+ const { tokenWithInfo } = await this.ensureProviderToken(tokenOptInfo);
1810
+
1811
+ try {
1812
+ return await requestJiraIssueByKey(this.request, tokenWithInfo.accessToken, resourceId, resourceUrl, key);
1813
+ } catch (e) {
1814
+ const status = (e as { response?: { status?: unknown } }).response?.status;
1815
+ if (status === 404) return undefined;
1816
+
1817
+ return this.handleProviderError<ProviderIssue | undefined>(tokenWithInfo, e);
1818
+ }
1819
+ }
1820
+
1840
1821
  async getIssue(
1841
1822
  tokenOptInfo: TokenWithInfo,
1842
1823
  input: { resourceId: string; number: string } | { namespace: string; name: string; number: string },
@@ -1854,6 +1835,8 @@ export class ProvidersApi {
1854
1835
 
1855
1836
  return result?.data;
1856
1837
  } catch (e) {
1838
+ if (isProviderIssueNotFoundError(tokenWithInfo.providerId, e)) return undefined;
1839
+
1857
1840
  return this.handleProviderError<ProviderIssue | undefined>(tokenWithInfo, e);
1858
1841
  }
1859
1842
  }
@@ -1896,28 +1879,3 @@ async function parseFetchResponseForApi<T>(response: Response): Promise<Provider
1896
1879
 
1897
1880
  return result;
1898
1881
  }
1899
-
1900
- const maxProviderErrorBodyLength = 500;
1901
-
1902
- /** Extracts the useful provider prose from an already-parsed SDK response body. */
1903
- function getProviderResponseBodyMessage(body: unknown): string | undefined {
1904
- let message: string | undefined;
1905
- if (typeof body === 'string') {
1906
- message = body;
1907
- } else if (body != null && typeof body === 'object') {
1908
- const { message: direct, error } = body as { message?: unknown; error?: unknown };
1909
- if (typeof direct === 'string') {
1910
- message = direct;
1911
- } else if (typeof error === 'string') {
1912
- message = error;
1913
- } else if (error != null && typeof error === 'object') {
1914
- const nested = (error as { message?: unknown }).message;
1915
- if (typeof nested === 'string') {
1916
- message = nested;
1917
- }
1918
- }
1919
- }
1920
-
1921
- message = message?.trim();
1922
- return message ? message.slice(0, maxProviderErrorBodyLength) : undefined;
1923
- }
@@ -1,4 +1,3 @@
1
- import type { CollectionMetadata } from '@gitkraken/provider-apis';
2
1
  import type { Account } from '../../../git/models/author.js';
3
2
  import type { Issue, IssueShape } from '../../../git/models/issue.js';
4
3
  import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest.js';
@@ -9,7 +8,7 @@ import type { ProviderAuthenticationSession } from '../authentication/models.js'
9
8
  import { toTokenWithInfo } from '../authentication/models.js';
10
9
  import { IssuesCloudHostIntegrationId } from '../constants.js';
11
10
  import { IntegrationReadUnavailableError } from '../errors.js';
12
- import type { IssuesForProjectOptions } from '../models/issueReads.js';
11
+ import type { IssuesForProjectOptions, ProjectIssuesDrain } from '../models/issueReads.js';
13
12
  import { IssuesIntegration } from '../models/issuesIntegration.js';
14
13
  import { fromProviderIssue, providersMetadata, toIssueShape } from './models.js';
15
14
 
@@ -104,7 +103,7 @@ export class TrelloIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
104
103
  session: ProviderAuthenticationSession,
105
104
  project: ResourceDescriptor,
106
105
  options?: IssuesForProjectOptions,
107
- ): Promise<{ values: IssueShape[]; truncated: boolean; metadata?: CollectionMetadata } | undefined> {
106
+ ): Promise<ProjectIssuesDrain | undefined> {
108
107
  // A non-issue descriptor genuinely has nothing to read (empty), but a missing app key is a broken read.
109
108
  if (!isIssueResourceDescriptor(project)) return undefined;
110
109
 
@@ -141,7 +140,9 @@ export class TrelloIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
141
140
  // never a cursor. Surface that as terminal truncation; there is no next page to fetch, so retrying the
142
141
  // same read cannot recover the omitted cards (D11).
143
142
  const truncated = result.metadata != null && result.metadata.completeness !== 'complete';
144
- return { values: values, truncated: truncated, metadata: result.metadata };
143
+ return truncated
144
+ ? { values: values, truncated: true, recovery: 'none', metadata: result.metadata }
145
+ : { values: values, truncated: false, metadata: result.metadata };
145
146
  }
146
147
 
147
148
  protected override searchProviderMyIssues(
@@ -32,17 +32,24 @@ export function parsePageCursor(cursor: string | undefined): number | undefined
32
32
  }
33
33
 
34
34
  /** Preserves successful sibling scopes, but doesn't turn an all-scope provider failure into an empty success. */
35
- export async function flatSettledOrThrow<T>(promises: Promise<T[]>[]): Promise<T[]> {
36
- const results = await Promise.allSettled(promises);
37
- const fulfilled = results.filter((result): result is PromiseFulfilledResult<T[]> => result.status === 'fulfilled');
38
- if (fulfilled.length === 0) {
39
- const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
40
- if (rejected != null) throw rejected.reason;
41
- }
35
+ export function throwIfAllSettledFailed<T>(results: PromiseSettledResult<T>[]): void {
36
+ if (results.some(result => result.status === 'fulfilled')) return;
37
+
38
+ const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
39
+ if (rejected != null) throw rejected.reason;
40
+ }
42
41
 
42
+ export function flatSettledResultsOrThrow<T>(results: PromiseSettledResult<T[]>[]): T[] {
43
+ throwIfAllSettledFailed(results);
44
+
45
+ const fulfilled = results.filter((result): result is PromiseFulfilledResult<T[]> => result.status === 'fulfilled');
43
46
  return fulfilled.flatMap(result => result.value);
44
47
  }
45
48
 
49
+ export async function flatSettledOrThrow<T>(promises: Promise<T[]>[]): Promise<T[]> {
50
+ return flatSettledResultsOrThrow(await Promise.allSettled(promises));
51
+ }
52
+
46
53
  /** Precedence for merged completeness: any known omission (`partial`) wins, then inability to confirm
47
54
  * (`unknown`), and only an all-`complete` set of pages stays `complete`. */
48
55
  const completenessRank: Record<CollectionCompleteness, number> = { partial: 2, unknown: 1, complete: 0 };
@@ -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);