@gitkraken/core-gitlens 0.5.114 → 0.5.115

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 (138) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  3. package/dist/plus/git-github/api/github.js +37 -37
  4. package/dist/plus/git-github/api/github.js.map +1 -1
  5. package/dist/plus/integrations/collectionMetadata.d.ts +2 -0
  6. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  7. package/dist/plus/integrations/collectionMetadata.js +17 -1
  8. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  9. package/dist/plus/integrations/context.d.ts +4 -1
  10. package/dist/plus/integrations/context.d.ts.map +1 -1
  11. package/dist/plus/integrations/index.d.ts +1 -0
  12. package/dist/plus/integrations/index.d.ts.map +1 -1
  13. package/dist/plus/integrations/integrationService.d.ts +9 -1
  14. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  15. package/dist/plus/integrations/integrationService.js +8 -4
  16. package/dist/plus/integrations/integrationService.js.map +1 -1
  17. package/dist/plus/integrations/manager.d.ts +22 -0
  18. package/dist/plus/integrations/manager.d.ts.map +1 -1
  19. package/dist/plus/integrations/models/gitHostIntegration.d.ts +22 -26
  20. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  21. package/dist/plus/integrations/models/gitHostIntegration.js +92 -47
  22. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  23. package/dist/plus/integrations/models/integration.d.ts +9 -0
  24. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  25. package/dist/plus/integrations/models/integration.js +52 -19
  26. package/dist/plus/integrations/models/integration.js.map +1 -1
  27. package/dist/plus/integrations/models/issueCache.d.ts +16 -0
  28. package/dist/plus/integrations/models/issueCache.d.ts.map +1 -0
  29. package/dist/plus/integrations/models/issueCache.js +9 -0
  30. package/dist/plus/integrations/models/issueCache.js.map +1 -0
  31. package/dist/plus/integrations/models/issueReads.d.ts +13 -0
  32. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -1
  33. package/dist/plus/integrations/models/issuesIntegration.d.ts +11 -13
  34. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  35. package/dist/plus/integrations/models/issuesIntegration.js +15 -1
  36. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  37. package/dist/plus/integrations/providers/azureDevOps.d.ts +3 -5
  38. package/dist/plus/integrations/providers/azureDevOps.d.ts.map +1 -1
  39. package/dist/plus/integrations/providers/azureDevOps.js +9 -8
  40. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  41. package/dist/plus/integrations/providers/bitbucket-server.d.ts +3 -5
  42. package/dist/plus/integrations/providers/bitbucket-server.d.ts.map +1 -1
  43. package/dist/plus/integrations/providers/bitbucket-server.js +2 -2
  44. package/dist/plus/integrations/providers/bitbucket-server.js.map +1 -1
  45. package/dist/plus/integrations/providers/bitbucket.d.ts +3 -5
  46. package/dist/plus/integrations/providers/bitbucket.d.ts.map +1 -1
  47. package/dist/plus/integrations/providers/bitbucket.js +2 -1
  48. package/dist/plus/integrations/providers/bitbucket.js.map +1 -1
  49. package/dist/plus/integrations/providers/github.d.ts +3 -5
  50. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  51. package/dist/plus/integrations/providers/github.js +3 -3
  52. package/dist/plus/integrations/providers/github.js.map +1 -1
  53. package/dist/plus/integrations/providers/gitlab.d.ts +3 -5
  54. package/dist/plus/integrations/providers/gitlab.d.ts.map +1 -1
  55. package/dist/plus/integrations/providers/gitlab.js +2 -2
  56. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  57. package/dist/plus/integrations/providers/jira.d.ts +3 -7
  58. package/dist/plus/integrations/providers/jira.d.ts.map +1 -1
  59. package/dist/plus/integrations/providers/jira.js +35 -22
  60. package/dist/plus/integrations/providers/jira.js.map +1 -1
  61. package/dist/plus/integrations/providers/jiraIssueByKey.d.ts +7 -0
  62. package/dist/plus/integrations/providers/jiraIssueByKey.d.ts.map +1 -0
  63. package/dist/plus/integrations/providers/jiraIssueByKey.js +102 -0
  64. package/dist/plus/integrations/providers/jiraIssueByKey.js.map +1 -0
  65. package/dist/plus/integrations/providers/linear.d.ts +3 -7
  66. package/dist/plus/integrations/providers/linear.d.ts.map +1 -1
  67. package/dist/plus/integrations/providers/linear.js +23 -22
  68. package/dist/plus/integrations/providers/linear.js.map +1 -1
  69. package/dist/plus/integrations/providers/models.d.ts +3 -1
  70. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  71. package/dist/plus/integrations/providers/models.js +10 -4
  72. package/dist/plus/integrations/providers/models.js.map +1 -1
  73. package/dist/plus/integrations/providers/providerErrors.d.ts +7 -0
  74. package/dist/plus/integrations/providers/providerErrors.d.ts.map +1 -0
  75. package/dist/plus/integrations/providers/providerErrors.js +91 -0
  76. package/dist/plus/integrations/providers/providerErrors.js.map +1 -0
  77. package/dist/plus/integrations/providers/providersApi.d.ts +3 -2
  78. package/dist/plus/integrations/providers/providersApi.d.ts.map +1 -1
  79. package/dist/plus/integrations/providers/providersApi.js +18 -52
  80. package/dist/plus/integrations/providers/providersApi.js.map +1 -1
  81. package/dist/plus/integrations/providers/trello.d.ts +2 -7
  82. package/dist/plus/integrations/providers/trello.d.ts.map +1 -1
  83. package/dist/plus/integrations/providers/trello.js +3 -1
  84. package/dist/plus/integrations/providers/trello.js.map +1 -1
  85. package/dist/plus/integrations/providers/utils/providerPaging.d.ts +2 -0
  86. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  87. package/dist/plus/integrations/providers/utils/providerPaging.js +12 -7
  88. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  89. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  90. package/dist/plus/integrations/reads/issueTracker.js +23 -17
  91. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  92. package/dist/plus/integrations/reads/trackerIssue.d.ts +21 -0
  93. package/dist/plus/integrations/reads/trackerIssue.d.ts.map +1 -0
  94. package/dist/plus/integrations/reads/trackerIssue.js +51 -0
  95. package/dist/plus/integrations/reads/trackerIssue.js.map +1 -0
  96. package/dist/plus/integrations/reads/warnings.d.ts +2 -15
  97. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  98. package/dist/plus/integrations/reads/warnings.js +19 -1
  99. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  100. package/dist/plus/integrations/results.d.ts +4 -20
  101. package/dist/plus/integrations/results.d.ts.map +1 -1
  102. package/dist/plus/integrations/results.js.map +1 -1
  103. package/dist/utils/promise.d.ts +2 -0
  104. package/dist/utils/promise.d.ts.map +1 -1
  105. package/dist/utils/promise.js +11 -0
  106. package/dist/utils/promise.js.map +1 -1
  107. package/docs/integrations.md +37 -26
  108. package/docs/kepler-read-api-parity.md +13 -0
  109. package/package.json +2 -2
  110. package/src/plus/git-github/api/github.ts +51 -58
  111. package/src/plus/integrations/collectionMetadata.ts +23 -1
  112. package/src/plus/integrations/context.ts +1 -1
  113. package/src/plus/integrations/index.ts +1 -0
  114. package/src/plus/integrations/integrationService.ts +14 -4
  115. package/src/plus/integrations/manager.ts +22 -0
  116. package/src/plus/integrations/models/gitHostIntegration.ts +117 -129
  117. package/src/plus/integrations/models/integration.ts +56 -22
  118. package/src/plus/integrations/models/issueCache.ts +29 -0
  119. package/src/plus/integrations/models/issueReads.ts +15 -0
  120. package/src/plus/integrations/models/issuesIntegration.ts +38 -8
  121. package/src/plus/integrations/providers/azureDevOps.ts +16 -13
  122. package/src/plus/integrations/providers/bitbucket-server.ts +4 -6
  123. package/src/plus/integrations/providers/bitbucket.ts +3 -4
  124. package/src/plus/integrations/providers/github.ts +5 -7
  125. package/src/plus/integrations/providers/gitlab.ts +4 -6
  126. package/src/plus/integrations/providers/jira.ts +52 -26
  127. package/src/plus/integrations/providers/jiraIssueByKey.ts +155 -0
  128. package/src/plus/integrations/providers/linear.ts +38 -25
  129. package/src/plus/integrations/providers/models.ts +17 -5
  130. package/src/plus/integrations/providers/providerErrors.ts +115 -0
  131. package/src/plus/integrations/providers/providersApi.ts +26 -68
  132. package/src/plus/integrations/providers/trello.ts +5 -4
  133. package/src/plus/integrations/providers/utils/providerPaging.ts +14 -7
  134. package/src/plus/integrations/reads/issueTracker.ts +26 -24
  135. package/src/plus/integrations/reads/trackerIssue.ts +105 -0
  136. package/src/plus/integrations/reads/warnings.ts +23 -17
  137. package/src/plus/integrations/results.ts +4 -20
  138. package/src/utils/promise.ts +15 -0
@@ -12,7 +12,12 @@ import { toTokenWithInfo } from '../authentication/models.js';
12
12
  import { toCollectionScopeFailure } from '../collectionMetadata.js';
13
13
  import { IssuesCloudHostIntegrationId } from '../constants.js';
14
14
  import { IntegrationReadUnavailableError } from '../errors.js';
15
- import type { AccountWideIssuesResult, IssuesForProjectOptions, SearchMyIssuesOptions } from '../models/issueReads.js';
15
+ import type {
16
+ AccountWideIssuesResult,
17
+ IssuesForProjectOptions,
18
+ ProjectIssuesDrain,
19
+ SearchMyIssuesOptions,
20
+ } from '../models/issueReads.js';
16
21
  import { IssuesIntegration } from '../models/issuesIntegration.js';
17
22
  import type { ProviderApiCollectionResult, ProviderIssue } from './models.js';
18
23
  import { fromProviderIssue, providersMetadata, toIssueShape } from './models.js';
@@ -219,7 +224,7 @@ export class LinearIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
219
224
  session: ProviderAuthenticationSession,
220
225
  project: ResourceDescriptor,
221
226
  options?: IssuesForProjectOptions,
222
- ): Promise<{ values: IssueShape[]; truncated: boolean; metadata?: CollectionMetadata } | undefined> {
227
+ ): Promise<ProjectIssuesDrain | undefined> {
223
228
  if (!isIssueResourceDescriptor(project)) return undefined;
224
229
 
225
230
  const api = await this.getProvidersApi();
@@ -256,6 +261,8 @@ export class LinearIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
256
261
  requestCount += 1;
257
262
  hasMore = result.paging?.more ?? false;
258
263
  const nextCursor = result.paging?.cursor;
264
+ truncated ||= result.paging?.truncated === true;
265
+ collectionMetadata = mergeCollectionMetadata(collectionMetadata, result.metadata);
259
266
  for (const issue of result.values) {
260
267
  const shape = toIssueShape(issue, this);
261
268
  if (shape != null) {
@@ -270,7 +277,6 @@ export class LinearIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
270
277
  }
271
278
 
272
279
  cursor = nextCursor;
273
- // More pages remain but we've hit the backstop: the drain is incomplete.
274
280
  if (hasMore && requestCount >= maxPagesPerRequest) {
275
281
  truncated = true;
276
282
  }
@@ -294,14 +300,16 @@ export class LinearIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
294
300
  'could not resolve the current user to scope issues to',
295
301
  );
296
302
  }
297
- return {
298
- values: issues.filter(issue => issue.assignees?.some(a => a.id === viewerId)),
299
- truncated: truncated,
300
- metadata: collectionMetadata,
301
- };
303
+
304
+ const values = issues.filter(issue => issue.assignees?.some(a => a.id === viewerId));
305
+ return truncated
306
+ ? { values: values, truncated: true, recovery: 'none', metadata: collectionMetadata }
307
+ : { values: values, truncated: false, metadata: collectionMetadata };
302
308
  }
303
309
 
304
- return { values: issues, truncated: truncated, metadata: collectionMetadata };
310
+ return truncated
311
+ ? { values: issues, truncated: true, recovery: 'none', metadata: collectionMetadata }
312
+ : { values: issues, truncated: false, metadata: collectionMetadata };
305
313
  }
306
314
 
307
315
  override get id(): IssuesCloudHostIntegrationId.Linear {
@@ -433,30 +441,35 @@ export class LinearIntegration extends IssuesIntegration<IssuesCloudHostIntegrat
433
441
  return result && fromProviderIssue(result, this);
434
442
  }
435
443
 
444
+ protected override async getProviderIssueByResourceId(
445
+ session: ProviderAuthenticationSession,
446
+ resourceId: string,
447
+ id: string,
448
+ _resourceUrl: string | undefined,
449
+ ): Promise<Issue | undefined> {
450
+ const api = await this.getProvidersApi();
451
+ const result = await api.getIssue(toTokenWithInfo(this.id, session), {
452
+ resourceId: resourceId,
453
+ number: id,
454
+ });
455
+ return result && fromProviderIssue(result, this);
456
+ }
457
+
436
458
  private async getRawProviderIssue(
437
459
  session: ProviderAuthenticationSession,
438
460
  resource: ResourceDescriptor,
439
461
  id: string,
440
462
  ): Promise<ProviderIssue | undefined> {
441
463
  const api = await this.getProvidersApi();
442
- try {
443
- if (!isIssueResourceDescriptor(resource)) {
444
- Logger.error(undefined, 'getProviderIssue: resource is not an IssueResourceDescriptor');
445
- return undefined;
446
- }
447
-
448
- const result = await api.getIssue(toTokenWithInfo(this.id, session), {
449
- resourceId: resource.id,
450
- number: id,
451
- });
452
-
453
- if (result == null) return undefined;
454
-
455
- return result;
456
- } catch (ex) {
457
- Logger.error(ex, 'getProviderIssue');
464
+ if (!isIssueResourceDescriptor(resource)) {
465
+ Logger.error(undefined, 'getProviderIssue: resource is not an IssueResourceDescriptor');
458
466
  return undefined;
459
467
  }
468
+
469
+ return api.getIssue(toTokenWithInfo(this.id, session), {
470
+ resourceId: resource.id,
471
+ number: id,
472
+ });
460
473
  }
461
474
  private getIssueAutolinkLikeUrl(issue: ProviderIssue): string | null {
462
475
  const url = issue.url;
@@ -1229,13 +1229,24 @@ function toIssueIdentifier(value: string | number): string {
1229
1229
  return String(value);
1230
1230
  }
1231
1231
 
1232
- export function toIssueShape(issue: ProviderIssue, provider: ProviderReference): IssueShape | undefined {
1232
+ export function toIssueShape(
1233
+ issue: ProviderIssue,
1234
+ provider: ProviderReference,
1235
+ options?: { reliableStateCategory?: boolean },
1236
+ ): IssueShape | undefined {
1233
1237
  // TODO: Add some protections/baselines rather than killing the transformation here
1234
1238
  // `author` is intentionally not required: some providers have no per-item creator (e.g. Trello cards,
1235
1239
  // which the SDK maps with `author: null`), and dropping every such item would discard the whole board.
1236
1240
  // Fall back to an empty author instead so these issues still surface.
1237
1241
  if (issue.updatedDate == null || issue.url == null) return undefined;
1238
1242
 
1243
+ // Jira SDK results derive this category from a localized display name and default unknown names to DONE.
1244
+ // 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));
1249
+
1239
1250
  return {
1240
1251
  type: 'issue',
1241
1252
  provider: provider,
@@ -1254,8 +1265,8 @@ export function toIssueShape(issue: ProviderIssue, provider: ProviderReference):
1254
1265
  createdDate: issue.createdDate,
1255
1266
  updatedDate: issue.updatedDate,
1256
1267
  closedDate: issue.closedDate ?? undefined,
1257
- closed: issue.closedDate != null,
1258
- state: issue.closedDate != null ? 'closed' : 'opened',
1268
+ closed: closed,
1269
+ state: closed ? 'closed' : 'opened',
1259
1270
  author: {
1260
1271
  id: issue.author?.id ?? '',
1261
1272
  // An absent name stays absent, matching {@link fromProviderAccount}; see `IssueMember.name`.
@@ -1688,6 +1699,7 @@ export function fromProviderIssue(
1688
1699
  options?: { project?: IssueProject },
1689
1700
  ): Issue {
1690
1701
  const identifier = toIssueIdentifier(issue.number);
1702
+ const closed = issue.closedDate != null || issue.state?.category === 'DONE';
1691
1703
  return new Issue(
1692
1704
  integration,
1693
1705
  identifier,
@@ -1696,8 +1708,8 @@ export function fromProviderIssue(
1696
1708
  issue.url ?? '',
1697
1709
  issue.createdDate,
1698
1710
  issue.updatedDate ?? issue.closedDate ?? issue.createdDate,
1699
- issue.closedDate != null,
1700
- issue.closedDate != null ? 'closed' : 'opened',
1711
+ closed,
1712
+ closed ? 'closed' : 'opened',
1701
1713
  fromProviderAccount(issue.author),
1702
1714
  issue.assignees?.map(fromProviderAccount) ?? undefined,
1703
1715
  issue.repository != null
@@ -0,0 +1,115 @@
1
+ import type { TokenWithInfo } from '../authentication/models.js';
2
+ import type { IntegrationIds } from '../constants.js';
3
+ import { IssuesCloudHostIntegrationId } from '../constants.js';
4
+ import {
5
+ AuthenticationError,
6
+ AuthenticationErrorReason,
7
+ isRateLimitResponse,
8
+ RequestClientError,
9
+ RequestNotFoundError,
10
+ toError,
11
+ toRateLimitError,
12
+ } from '../errors.js';
13
+
14
+ const linearIssueNotFoundMessage = /^Linear issue not found: .+$/i;
15
+ const maxProviderErrorBodyLength = 500;
16
+
17
+ type ProviderGraphQLError = {
18
+ message?: unknown;
19
+ extensions?: { code?: unknown };
20
+ };
21
+
22
+ function getProviderGraphQLErrors(body: unknown): ProviderGraphQLError[] {
23
+ if (body == null || typeof body !== 'object') return [];
24
+
25
+ const errors = (body as { errors?: unknown }).errors;
26
+ return Array.isArray(errors)
27
+ ? errors.filter((error): error is ProviderGraphQLError => error != null && typeof error === 'object')
28
+ : [];
29
+ }
30
+
31
+ function isLinearRateLimitError(providerId: IntegrationIds, ex: unknown): boolean {
32
+ if (providerId !== IssuesCloudHostIntegrationId.Linear) return false;
33
+
34
+ const body = (ex as { response?: { body?: unknown } }).response?.body;
35
+ return getProviderGraphQLErrors(body).some(error => error.extensions?.code === 'RATELIMITED');
36
+ }
37
+
38
+ export function isProviderIssueNotFoundError(providerId: IntegrationIds, ex: unknown): boolean {
39
+ if (providerId === IssuesCloudHostIntegrationId.Linear) {
40
+ return ex instanceof Error && linearIssueNotFoundMessage.test(ex.message);
41
+ }
42
+
43
+ const status = (ex as { response?: { status?: unknown } }).response?.status;
44
+ return status === 404 || status === 410 || status === 422;
45
+ }
46
+
47
+ export function throwProviderError(tokenWithInfo: TokenWithInfo, error: unknown): never {
48
+ const { accessToken: token, ...tokenInfo } = tokenWithInfo;
49
+ const providerId = tokenWithInfo.providerId;
50
+ const normalizedError = toError(error);
51
+ // Linear reports GraphQL throttling as HTTP 400, so its structured code must win over generic 4xx handling.
52
+ if (isLinearRateLimitError(providerId, error)) {
53
+ throw toRateLimitError(normalizedError, token);
54
+ }
55
+
56
+ const status = (error as { response?: { status?: unknown } }).response?.status;
57
+ if (typeof status === 'number') {
58
+ switch (status) {
59
+ case 404:
60
+ case 410:
61
+ case 422:
62
+ throw new RequestNotFoundError(normalizedError);
63
+ case 429:
64
+ throw toRateLimitError(normalizedError, token);
65
+ case 401:
66
+ case 403:
67
+ // Some hosts overload 403 for throttling; classify that before asking the user to reconnect.
68
+ if (isRateLimitResponse({ status: status, message: normalizedError.message })) {
69
+ throw toRateLimitError(normalizedError, token);
70
+ }
71
+ throw new AuthenticationError(
72
+ tokenInfo,
73
+ status === 401 ? AuthenticationErrorReason.Unauthorized : AuthenticationErrorReason.Forbidden,
74
+ normalizedError,
75
+ );
76
+ default:
77
+ if (status >= 400 && status < 500) {
78
+ throw new RequestClientError(normalizedError);
79
+ }
80
+ }
81
+ }
82
+
83
+ throw error;
84
+ }
85
+
86
+ /** Extracts the useful provider prose from an already-parsed SDK response body. */
87
+ export function getProviderResponseBodyMessage(body: unknown): string | undefined {
88
+ let message: string | undefined;
89
+ if (typeof body === 'string') {
90
+ message = body;
91
+ } else if (body != null && typeof body === 'object') {
92
+ const { message: direct, error } = body as { message?: unknown; error?: unknown };
93
+ if (typeof direct === 'string') {
94
+ message = direct;
95
+ } else if (typeof error === 'string') {
96
+ message = error;
97
+ } else if (error != null && typeof error === 'object') {
98
+ const nested = (error as { message?: unknown }).message;
99
+ if (typeof nested === 'string') {
100
+ message = nested;
101
+ }
102
+ }
103
+ if (message == null) {
104
+ const graphQLError = getProviderGraphQLErrors(body)[0];
105
+ if (typeof graphQLError?.message === 'string') {
106
+ message = graphQLError.message;
107
+ } else if (typeof graphQLError?.extensions?.code === 'string') {
108
+ message = graphQLError.extensions.code;
109
+ }
110
+ }
111
+ }
112
+
113
+ message = message?.trim();
114
+ return message ? message.slice(0, maxProviderErrorBodyLength) : undefined;
115
+ }
@@ -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 };
@@ -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
  }