@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
@@ -10,7 +10,7 @@ import type { ProviderAuthenticationSession } from '../authentication/models.js'
10
10
  import { toTokenWithInfo } from '../authentication/models.js';
11
11
  import { throwIfCallerContractError, toCollectionScopeFailure } from '../collectionMetadata.js';
12
12
  import { IssuesCloudHostIntegrationId } from '../constants.js';
13
- import type { IssuesForProjectOptions } from '../models/issueReads.js';
13
+ import type { IssuesForProjectOptions, ProjectIssuesDrain } from '../models/issueReads.js';
14
14
  import { IssuesIntegration } from '../models/issuesIntegration.js';
15
15
  import type { ProviderApiCollectionResult, ProviderIssue } from './models.js';
16
16
  import { IssueFilter, providersMetadata, toAccount, toIssueShape } from './models.js';
@@ -20,6 +20,12 @@ const metadata = providersMetadata[IssuesCloudHostIntegrationId.Jira];
20
20
  const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes });
21
21
  const maxPagesPerRequest = 10;
22
22
 
23
+ type JiraProjectIssuesDrain<T> = {
24
+ issues: T[];
25
+ status: 'complete' | 'backstop' | 'incomplete';
26
+ metadata?: CollectionMetadata;
27
+ };
28
+
23
29
  export type JiraBaseDescriptor = IssueResourceDescriptor;
24
30
 
25
31
  export interface JiraOrganizationDescriptor extends JiraBaseDescriptor {
@@ -244,16 +250,11 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
244
250
  session: ProviderAuthenticationSession,
245
251
  project: JiraProjectDescriptor,
246
252
  options?: IssuesForProjectOptions,
247
- ): Promise<{ values: IssueShape[]; truncated: boolean; metadata?: CollectionMetadata } | undefined> {
253
+ ): Promise<ProjectIssuesDrain | undefined> {
248
254
  const tokenWithInfo = toTokenWithInfo(this.id, session);
249
255
 
250
256
  const api = await this.getProvidersApi();
251
257
 
252
- // Drain every page for a project read (bounded by a defensive backstop): the paged wrapper preserves the
253
- // SDK's cursor, unlike the plain read which silently caps at the first page. `filter` undefined = the
254
- // unscoped project read. Reports `truncated` when the drain stopped at the backstop with more pages
255
- // still available, and records a structured failure if a page-level error discarded the rest of the drain,
256
- // so the facade can warn + set fetchFailed while preserving the already-fetched prefix.
257
258
  const projectScope = {
258
259
  providerId: this.id,
259
260
  resourceId: project.resourceId,
@@ -263,10 +264,10 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
263
264
  authorLogin?: string;
264
265
  assigneeLogins?: string[];
265
266
  mentionLogin?: string;
266
- }): Promise<{ issues: ProviderIssue[]; truncated: boolean; metadata?: CollectionMetadata }> => {
267
+ }): Promise<JiraProjectIssuesDrain<ProviderIssue>> => {
267
268
  const collected: ProviderIssue[] = [];
268
269
  let cursor: string | undefined;
269
- let truncated = false;
270
+ let status: JiraProjectIssuesDrain<ProviderIssue>['status'] = 'complete';
270
271
  let metadata: CollectionMetadata | undefined;
271
272
  for (let i = 0; i < maxPagesPerRequest; i++) {
272
273
  let result: Awaited<ReturnType<typeof api.getIssuesForProjectPaged>> | undefined;
@@ -283,7 +284,7 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
283
284
  // than an empty partial success.
284
285
  if (collected.length === 0) throw ex;
285
286
 
286
- truncated = true;
287
+ status = 'incomplete';
287
288
  metadata = mergeCollectionMetadata(metadata, {
288
289
  completeness: 'partial',
289
290
  failures: [toCollectionScopeFailure(projectScope, ex)],
@@ -293,7 +294,7 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
293
294
  if (result == null) {
294
295
  if (cursor == null) break;
295
296
 
296
- truncated = true;
297
+ status = 'incomplete';
297
298
  metadata = mergeCollectionMetadata(metadata, {
298
299
  completeness: 'partial',
299
300
  failures: [
@@ -312,7 +313,7 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
312
313
  // The provider claims more pages but gave no advancing cursor: we can't continue, so the drain
313
314
  // is incomplete — flag it rather than silently stopping (matches drainPullRequests/Repositories).
314
315
  if (result.nextCursor == null || result.nextCursor === cursor) {
315
- truncated = true;
316
+ status = 'incomplete';
316
317
  metadata = mergeCollectionMetadata(metadata, {
317
318
  completeness: 'partial',
318
319
  failures: [
@@ -326,18 +327,21 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
326
327
  }
327
328
 
328
329
  cursor = result.nextCursor;
329
- // More pages remain but we're at the last allowed iteration: the drain is incomplete.
330
330
  if (i === maxPagesPerRequest - 1) {
331
- truncated = true;
331
+ status = 'backstop';
332
332
  }
333
333
  }
334
- return { issues: collected, truncated: truncated, metadata: metadata };
334
+ return {
335
+ issues: collected,
336
+ status: status,
337
+ metadata: metadata,
338
+ };
335
339
  };
336
340
 
337
341
  const getSearchedUserIssuesForFilter = async (
338
342
  user: string,
339
343
  filter: IssueFilter,
340
- ): Promise<{ issues: IssueShape[]; truncated: boolean; metadata?: CollectionMetadata }> => {
344
+ ): Promise<JiraProjectIssuesDrain<IssueShape>> => {
341
345
  const result = await drainIssues({
342
346
  authorLogin: filter === IssueFilter.Author ? user : undefined,
343
347
  assigneeLogins: filter === IssueFilter.Assignee ? [user] : undefined,
@@ -348,7 +352,7 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
348
352
  issues: result.issues
349
353
  .map(issue => toIssueShape(issue, this))
350
354
  .filter((r): r is IssueShape => r !== undefined),
351
- truncated: result.truncated,
355
+ status: result.status,
352
356
  metadata: result.metadata,
353
357
  };
354
358
  };
@@ -415,7 +419,7 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
415
419
  continue;
416
420
  }
417
421
 
418
- if (outcome.value.truncated) {
422
+ if (outcome.value.status !== 'complete') {
419
423
  truncated = true;
420
424
  }
421
425
  if (outcome.value.metadata != null) {
@@ -428,17 +432,23 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
428
432
  }
429
433
  }
430
434
 
431
- return { values: [...resultsById.values()], truncated: truncated, metadata: metadata };
435
+ return truncated
436
+ ? { values: [...resultsById.values()], truncated: true, recovery: 'none', metadata: metadata }
437
+ : { values: [...resultsById.values()], truncated: false, metadata: metadata };
432
438
  }
433
439
 
434
440
  const unscoped = await drainIssues({});
435
- return {
436
- values: unscoped.issues
437
- .map(issue => toIssueShape(issue, this))
438
- .filter((result): result is IssueShape => result !== undefined),
439
- truncated: unscoped.truncated,
440
- metadata: unscoped.metadata,
441
- };
441
+ const values = unscoped.issues
442
+ .map(issue => toIssueShape(issue, this))
443
+ .filter((result): result is IssueShape => result !== undefined);
444
+ return unscoped.status !== 'complete'
445
+ ? {
446
+ values: values,
447
+ truncated: true,
448
+ recovery: unscoped.status === 'backstop' ? 'narrow-scope' : 'none',
449
+ metadata: unscoped.metadata,
450
+ }
451
+ : { values: values, truncated: false, metadata: unscoped.metadata };
442
452
  }
443
453
 
444
454
  protected override async searchProviderMyIssues(
@@ -516,6 +526,22 @@ export class JiraIntegration extends IssuesIntegration<IssuesCloudHostIntegratio
516
526
  return issue != null ? { ...issue, type: 'issue' } : undefined;
517
527
  }
518
528
 
529
+ protected override async getProviderIssueByResourceId(
530
+ session: ProviderAuthenticationSession,
531
+ resourceId: string,
532
+ id: string,
533
+ resourceUrl: string | undefined,
534
+ ): Promise<Issue | undefined> {
535
+ if (resourceUrl == null) {
536
+ throw new Error('Jira direct issue reads require a resource URL');
537
+ }
538
+
539
+ const api = await this.getProvidersApi();
540
+ const apiResult = await api.getJiraIssueByKey(toTokenWithInfo(this.id, session), resourceId, resourceUrl, id);
541
+ const issue = apiResult != null ? toIssueShape(apiResult, this, { reliableStateCategory: true }) : undefined;
542
+ return issue != null ? { ...issue, type: 'issue' } : undefined;
543
+ }
544
+
519
545
  protected override async providerOnConnect(): Promise<void> {
520
546
  this._autolinks = undefined;
521
547
  if (this._session == null) return;
@@ -0,0 +1,155 @@
1
+ import type { ProviderAccount, ProviderIssue, ProviderRequestFunction } from './models.js';
2
+
3
+ const jiraCloudApiUrl = 'https://api.atlassian.com/ex/jira';
4
+ const jiraIssueByKeyFields = [
5
+ 'assignee',
6
+ 'comment',
7
+ 'created',
8
+ 'creator',
9
+ 'description',
10
+ 'issuetype',
11
+ 'labels',
12
+ 'project',
13
+ 'status',
14
+ 'summary',
15
+ 'updated',
16
+ 'votes',
17
+ ];
18
+
19
+ type JiraIssueMemberResponse = {
20
+ accountId?: string;
21
+ displayName?: string | null;
22
+ emailAddress?: string | null;
23
+ avatarUrls?: Record<string, string | undefined>;
24
+ };
25
+
26
+ type JiraIssueStatusResponse = {
27
+ id: string;
28
+ name: string;
29
+ statusCategory: {
30
+ colorName: string;
31
+ key: string;
32
+ name: string;
33
+ };
34
+ };
35
+
36
+ type JiraIssueByKeyResponse = {
37
+ id: string;
38
+ key: string;
39
+ fields: {
40
+ assignee?: JiraIssueMemberResponse | null;
41
+ comment?: { comments?: unknown[]; total?: number };
42
+ created: string;
43
+ creator?: JiraIssueMemberResponse | null;
44
+ description?: string | null;
45
+ issuetype?: { name?: string | null };
46
+ labels?: string[];
47
+ project?: { id?: string | null; key?: string | null; name?: string | null };
48
+ status: JiraIssueStatusResponse;
49
+ summary: string;
50
+ updated: string;
51
+ votes?: { votes?: number | null };
52
+ };
53
+ };
54
+
55
+ function toAccount(member: JiraIssueMemberResponse | null | undefined): ProviderAccount | null {
56
+ if (member == null) return null;
57
+
58
+ return {
59
+ id: member.accountId ?? '',
60
+ name: member.displayName ?? null,
61
+ username: member.displayName ?? null,
62
+ email: member.emailAddress ?? null,
63
+ avatarUrl: member.avatarUrls?.['48x48'] ?? null,
64
+ url: null,
65
+ };
66
+ }
67
+
68
+ function toStatusCategory(key: string): 'TO_DO' | 'IN_PROGRESS' | 'DONE' {
69
+ switch (key.toLowerCase()) {
70
+ case 'new':
71
+ return 'TO_DO';
72
+ case 'indeterminate':
73
+ return 'IN_PROGRESS';
74
+ case 'done':
75
+ return 'DONE';
76
+ default:
77
+ return 'TO_DO';
78
+ }
79
+ }
80
+
81
+ function toIssueWebUrl(resourceUrl: string, key: string): string {
82
+ const url = new URL(resourceUrl);
83
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/browse/${encodeURIComponent(key)}`;
84
+ url.search = '';
85
+ url.hash = '';
86
+ return url.toString();
87
+ }
88
+
89
+ function fromJiraIssueByKey(issue: JiraIssueByKeyResponse, resourceId: string, resourceUrl: string): ProviderIssue {
90
+ const assignee = toAccount(issue.fields.assignee);
91
+ const project = issue.fields.project;
92
+ const status = issue.fields.status;
93
+
94
+ return {
95
+ id: issue.id,
96
+ commentCount: issue.fields.comment?.total ?? issue.fields.comment?.comments?.length ?? null,
97
+ number: issue.key,
98
+ title: issue.fields.summary,
99
+ url: toIssueWebUrl(resourceUrl, issue.key),
100
+ closedDate: null,
101
+ createdDate: new Date(issue.fields.created),
102
+ author: toAccount(issue.fields.creator),
103
+ updatedDate: new Date(issue.fields.updated),
104
+ assignees: assignee != null ? [assignee] : [],
105
+ description: issue.fields.description ?? null,
106
+ repository: null,
107
+ project:
108
+ project != null
109
+ ? {
110
+ name: project.name ?? '',
111
+ resourceId: resourceId,
112
+ key: project.key ?? null,
113
+ namespace: null,
114
+ id: project.id ?? null,
115
+ }
116
+ : undefined,
117
+ state: {
118
+ id: status.id,
119
+ name: status.name,
120
+ color: status.statusCategory.colorName,
121
+ category: toStatusCategory(status.statusCategory.key),
122
+ },
123
+ type: issue.fields.issuetype?.name ?? null,
124
+ upvoteCount: issue.fields.votes?.votes ?? null,
125
+ labels: (issue.fields.labels ?? []).map(label => ({
126
+ color: null,
127
+ description: null,
128
+ id: null,
129
+ name: label,
130
+ })),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * The SDK's Jira point read performs resource and field discovery before the issue GET. This focused path uses
136
+ * caller-supplied resource identity so a cold lookup remains one upstream request.
137
+ */
138
+ export async function requestJiraIssueByKey(
139
+ request: ProviderRequestFunction,
140
+ accessToken: string,
141
+ resourceId: string,
142
+ resourceUrl: string,
143
+ key: string,
144
+ ): Promise<ProviderIssue> {
145
+ const url = new URL(
146
+ `${jiraCloudApiUrl}/${encodeURIComponent(resourceId)}/rest/api/2/issue/${encodeURIComponent(key)}`,
147
+ );
148
+ url.searchParams.set('fields', jiraIssueByKeyFields.join(','));
149
+
150
+ const response = await request<JiraIssueByKeyResponse>({
151
+ url: url.toString(),
152
+ headers: { Authorization: `Bearer ${accessToken}` },
153
+ });
154
+ return fromJiraIssueByKey(response.body, resourceId, resourceUrl);
155
+ }
@@ -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
+ }