@gitkraken/core-gitlens 0.4.100 → 0.4.101

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 (65) hide show
  1. package/CHANGELOG.md +4 -1
  2. package/dist/plus/integrations/errors.d.ts.map +1 -1
  3. package/dist/plus/integrations/errors.js +6 -2
  4. package/dist/plus/integrations/errors.js.map +1 -1
  5. package/dist/plus/integrations/integrationService.d.ts +8 -5
  6. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  7. package/dist/plus/integrations/integrationService.js +20 -652
  8. package/dist/plus/integrations/integrationService.js.map +1 -1
  9. package/dist/plus/integrations/models/gitHostIntegration.d.ts +10 -17
  10. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  11. package/dist/plus/integrations/models/gitHostIntegration.js +38 -88
  12. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  13. package/dist/plus/integrations/models/issuesIntegration.d.ts +4 -21
  14. package/dist/plus/integrations/models/issuesIntegration.d.ts.map +1 -1
  15. package/dist/plus/integrations/models/issuesIntegration.js +5 -47
  16. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  17. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  18. package/dist/plus/integrations/reads/broaden.d.ts +20 -0
  19. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -0
  20. package/dist/plus/integrations/reads/broaden.js +326 -0
  21. package/dist/plus/integrations/reads/broaden.js.map +1 -0
  22. package/dist/plus/integrations/reads/context.d.ts +22 -0
  23. package/dist/plus/integrations/reads/context.d.ts.map +1 -1
  24. package/dist/plus/integrations/reads/hierarchy.d.ts +1 -1
  25. package/dist/plus/integrations/reads/hierarchy.d.ts.map +1 -1
  26. package/dist/plus/integrations/reads/hierarchy.js +109 -137
  27. package/dist/plus/integrations/reads/hierarchy.js.map +1 -1
  28. package/dist/plus/integrations/reads/hierarchy.utils.d.ts +25 -1
  29. package/dist/plus/integrations/reads/hierarchy.utils.d.ts.map +1 -1
  30. package/dist/plus/integrations/reads/hierarchy.utils.js +43 -0
  31. package/dist/plus/integrations/reads/hierarchy.utils.js.map +1 -1
  32. package/dist/plus/integrations/reads/issueTracker.d.ts +1 -1
  33. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  34. package/dist/plus/integrations/reads/issueTracker.js +13 -25
  35. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  36. package/dist/plus/integrations/reads/issues.d.ts +2 -2
  37. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  38. package/dist/plus/integrations/reads/issues.js.map +1 -1
  39. package/dist/plus/integrations/reads/pullRequests.d.ts +1 -1
  40. package/dist/plus/integrations/reads/pullRequests.d.ts.map +1 -1
  41. package/dist/plus/integrations/reads/pullRequests.js +1 -1
  42. package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
  43. package/dist/plus/integrations/reads/resolveRepository.d.ts +25 -0
  44. package/dist/plus/integrations/reads/resolveRepository.d.ts.map +1 -0
  45. package/dist/plus/integrations/reads/resolveRepository.js +235 -0
  46. package/dist/plus/integrations/reads/resolveRepository.js.map +1 -0
  47. package/dist/plus/integrations/reads/sweeps.d.ts +11 -0
  48. package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -0
  49. package/dist/plus/integrations/reads/sweeps.js +125 -0
  50. package/dist/plus/integrations/reads/sweeps.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/plus/integrations/errors.ts +6 -2
  53. package/src/plus/integrations/integrationService.ts +27 -801
  54. package/src/plus/integrations/models/gitHostIntegration.ts +71 -93
  55. package/src/plus/integrations/models/issuesIntegration.ts +5 -52
  56. package/src/plus/integrations/providers/azureDevOps.ts +1 -1
  57. package/src/plus/integrations/reads/broaden.ts +452 -0
  58. package/src/plus/integrations/reads/context.ts +24 -0
  59. package/src/plus/integrations/reads/hierarchy.ts +133 -153
  60. package/src/plus/integrations/reads/hierarchy.utils.ts +50 -1
  61. package/src/plus/integrations/reads/issueTracker.ts +15 -27
  62. package/src/plus/integrations/reads/issues.ts +4 -2
  63. package/src/plus/integrations/reads/pullRequests.ts +2 -9
  64. package/src/plus/integrations/reads/resolveRepository.ts +272 -0
  65. package/src/plus/integrations/reads/sweeps.ts +185 -0
@@ -1,9 +1,9 @@
1
1
  import { mapBounded } from '../../../utils/promise.js';
2
2
  import { mergeAssessmentInto } from '../collectionMetadata.js';
3
+ import type { IntegrationIds } from '../constants.js';
3
4
  import {
4
5
  GitCloudHostIntegrationId,
5
6
  GitSelfManagedHostIntegrationId,
6
- type IntegrationIds,
7
7
  providerFanOutConcurrency,
8
8
  supportedOrderedCloudIntegrationIds,
9
9
  supportedOrderedCloudIssuesIntegrationIds,
@@ -28,6 +28,7 @@ import type { ProviderReadContext } from './context.js';
28
28
  import { runCaptured } from './drains.js';
29
29
  import {
30
30
  assertHierarchyReadTarget,
31
+ mergeHierarchyResults,
31
32
  orgForProject,
32
33
  resourceMatchesOrg,
33
34
  resourceToOrg,
@@ -132,25 +133,7 @@ export async function listOrgs(
132
133
  return { items: items, warnings: warnings, fetchFailed: fetchFailed };
133
134
  });
134
135
 
135
- const items: ProviderOrganization[] = [];
136
- const warnings: ProviderWarning[] = [];
137
- let fetchFailed = false;
138
- for (const result of results) {
139
- if (result == null) {
140
- continue;
141
- }
142
-
143
- items.push(...result.items);
144
- // Dedupe across the multi-provider fan-out (matches listProjects): a warning that repeats verbatim
145
- // across scopes — e.g. the same account surfaced under two ids — shouldn't be reported twice.
146
- for (const w of result.warnings) {
147
- appendDedupedWarning(warnings, w);
148
- }
149
- if (result.fetchFailed) {
150
- fetchFailed = true;
151
- }
152
- }
153
- return { items: items, warnings: warnings, fetchFailed: fetchFailed || undefined };
136
+ return mergeHierarchyResults(results);
154
137
  }
155
138
 
156
139
  export async function listProjects(
@@ -281,23 +264,7 @@ export async function listProjects(
281
264
  return { items: items, warnings: warnings, fetchFailed: fetchFailed };
282
265
  });
283
266
 
284
- const items: ProviderOrganization[] = [];
285
- const warnings: ProviderWarning[] = [];
286
- let fetchFailed = false;
287
- for (const result of results) {
288
- if (result == null) {
289
- continue;
290
- }
291
-
292
- items.push(...result.items);
293
- for (const w of result.warnings) {
294
- appendDedupedWarning(warnings, w);
295
- }
296
- if (result.fetchFailed) {
297
- fetchFailed = true;
298
- }
299
- }
300
- return { items: items, warnings: warnings, fetchFailed: fetchFailed || undefined };
267
+ return mergeHierarchyResults(results);
301
268
  }
302
269
 
303
270
  export async function listRepos(
@@ -365,128 +332,141 @@ export async function listRepos(
365
332
  }
366
333
 
367
334
  const org = options.org;
368
- const cursor = options.cursor ?? pageToCursor(page);
369
- const { value, warning } = await runCaptured(
370
- options.providerId,
371
- domain,
372
- options.connectionId,
373
- () =>
374
- org == null
375
- ? integration.getRepositoriesForUserResult({
376
- cursor: cursor,
377
- connectionId: options.connectionId,
378
- })
379
- : integration.getRepositoriesForOrgResult(org, {
380
- project: options.project,
381
- cursor: cursor,
382
- connectionId: options.connectionId,
383
- }),
384
- { warnOnMissingSession: warnOnMissingSessionForDomain(options.providerId, options.domain) },
385
- );
335
+ const warnOnMissingSession = warnOnMissingSessionForDomain(options.providerId, options.domain);
336
+ const readPage = (cursor: string | undefined) =>
337
+ runCaptured(
338
+ options.providerId,
339
+ domain,
340
+ options.connectionId,
341
+ () =>
342
+ org == null
343
+ ? integration.getRepositoriesForUserResult({ cursor: cursor, connectionId: options.connectionId })
344
+ : integration.getRepositoriesForOrgResult(org, {
345
+ project: options.project,
346
+ cursor: cursor,
347
+ connectionId: options.connectionId,
348
+ }),
349
+ { warnOnMissingSession: warnOnMissingSession },
350
+ );
386
351
 
387
- const items = value?.values ?? [];
388
- const warnings = warning != null ? [warning] : [];
389
- // The repos read core is cursor-only and can't accept a page size, so don't echo the requested
390
- // `itemsPerPage` as if it were applied report what the provider returned (its own pageSize when
391
- // available, else the actual item count).
392
- const paged = toProviderPageInfo(items.length, value?.paging);
393
- // Convert the SDK collection metadata into scope-aware warnings + failure/truncation flags, appending
394
- // them to any captured thrown-error warning without discarding the partial result's items.
395
- const assessment = mergeAssessmentInto(warnings, options.providerId, domain, options.connectionId, value?.metadata);
396
- // Whether this read honors a page NUMBER, decided from what the provider actually reported rather than
397
- // guessed from the absence of a cursor. A numbered-page repos host reports its position (`paging.page`)
398
- // and/or its successor (`paging.nextPage`) Bitbucket's workspace walk consumes `page` as a real 1-based
399
- // page and reports `nextPage` without echoing `currentPage`, so both signals have to count. Every wired
400
- // cursor-based repos read (GitHub's org/user walks, GitLab's user walk, Bitbucket's cursor read) reports
401
- // neither. Keying off `paged.cursor == null` instead read the paging layer's "reported another page,
402
- // handed back no continuation" sentinel as evidence of a numbered host, which is exactly backwards.
403
- const pageAdvanceable = value?.paging?.page != null || value?.paging?.nextPage != null;
404
- // Numbered-page hosts that don't echo `currentPage` may still be advanced by the requested `page` (initial
405
- // read) or by the cursor the caller threaded back. A cursor-only host ignores an UNTHREADED `page`
406
- // request, so its page-less first page is page 1.
407
- const currentPage = resolveCurrentPage({
408
- providerPage: paged.page.currentPage,
409
- requestedPage: page,
410
- suppliedCursor: options.cursor,
411
- pageAdvanceable: pageAdvanceable,
412
- });
413
- // Never advertise `hasMore` without a continuation the caller can act on the same contract
414
- // `listPullRequestsPage`, `listIssuesPage` and `broadenIssues` hold, via the same helper. A provider that
415
- // reports another page but hands back neither `endCursor` nor `nextPage` (surfaced as the `'{}'` sentinel,
416
- // which `toProviderPageInfo` drops) is terminal-but-incomplete: `hasMore: false` + `page.truncated`.
417
- // Synthesizing a page-number cursor for a cursor-only host instead which is what this read used to do
418
- // unconditionally — handed back a continuation the provider ignores, so it answered with its FIRST page
419
- // again while still reporting `hasMore: true`. A consumer draining until `hasMore` clears (Kepler's repo
420
- // drain does exactly that) then looped to its own page cap, accumulating a duplicate copy of every repo
421
- // per round, and never saw a truncation signal.
422
- const continuation = resolveContinuation(paged, pageAdvanceable ? currentPage + 1 : undefined);
423
- // The org-hierarchy read can also stop at a defensive backstop with more repos unlisted and no cursor to
424
- // resume (top-level `truncated`, or `paging.truncated` on a single-page read). Metadata incompleteness and
425
- // a demoted continuation are two further, independent sources of the same signal.
426
- const truncated =
427
- (value?.truncated ?? false) ||
428
- (value?.paging?.truncated ?? false) ||
429
- assessment.truncated ||
430
- continuation.truncated;
431
- const result: ProviderPagedResult<ProviderRepositoryShape> = {
432
- // Normalize the raw provider-apis repos to the GitLens-owned shape at the surface boundary.
433
- items: items.map(toProviderRepositoryShape),
434
- warnings: warnings,
435
- page: { ...paged.page, currentPage: currentPage, truncated: truncated || undefined },
436
- hasMore: continuation.hasMore,
437
- cursor: continuation.cursor,
438
- fetchFailed: assessment.fetchFailed || (warning != null && value == null) || undefined,
352
+ /**
353
+ * Builds one page's result from what the provider returned. Shared by the initial read and by every round of
354
+ * the cursor walk below, so a walked page N is described by exactly the same rules as a directly-requested one
355
+ * (position, continuation, truncation) rather than by a second copy of them.
356
+ */
357
+ const toResult = (
358
+ { value, warning }: Awaited<ReturnType<typeof readPage>>,
359
+ requestedPage: number,
360
+ suppliedCursor: string | undefined,
361
+ ): ProviderPagedResult<ProviderRepositoryShape> => {
362
+ const items = value?.values ?? [];
363
+ const warnings = warning != null ? [warning] : [];
364
+ // The repos read core is cursor-only and can't accept a page size, so don't echo the requested
365
+ // `itemsPerPage` as if it were applied report what the provider returned (its own pageSize when
366
+ // available, else the actual item count).
367
+ const paged = toProviderPageInfo(items.length, value?.paging);
368
+ // Convert the SDK collection metadata into scope-aware warnings + failure/truncation flags, appending
369
+ // them to any captured thrown-error warning without discarding the partial result's items.
370
+ const assessment = mergeAssessmentInto(
371
+ warnings,
372
+ options.providerId,
373
+ domain,
374
+ options.connectionId,
375
+ value?.metadata,
376
+ );
377
+ // Whether this read honors a page NUMBER, decided from what the provider actually reported rather than
378
+ // guessed from the absence of a cursor. A numbered-page repos host reports its position (`paging.page`)
379
+ // and/or its successor (`paging.nextPage`) Bitbucket's workspace walk consumes `page` as a real 1-based
380
+ // page and reports `nextPage` without echoing `currentPage`, so both signals have to count. Every wired
381
+ // cursor-based repos read (GitHub's org/user walks, GitLab's user walk, Bitbucket's cursor read) reports
382
+ // neither. Keying off `paged.cursor == null` instead read the paging layer's "reported another page,
383
+ // handed back no continuation" sentinel as evidence of a numbered host, which is exactly backwards.
384
+ const pageAdvanceable = value?.paging?.page != null || value?.paging?.nextPage != null;
385
+ // Numbered-page hosts that don't echo `currentPage` may still be advanced by the requested `page` (initial
386
+ // read) or by the cursor the caller threaded back. A cursor-only host ignores an UNTHREADED `page`
387
+ // request, so its page-less first page is page 1.
388
+ const currentPage = resolveCurrentPage({
389
+ providerPage: paged.page.currentPage,
390
+ requestedPage: requestedPage,
391
+ suppliedCursor: suppliedCursor,
392
+ pageAdvanceable: pageAdvanceable,
393
+ });
394
+ // Never advertise `hasMore` without a continuation the caller can act on — the same contract
395
+ // `listPullRequestsPage`, `listIssuesPage` and `broadenIssues` hold, via the same helper. A provider that
396
+ // reports another page but hands back neither `endCursor` nor `nextPage` (surfaced as the `'{}'` sentinel,
397
+ // which `toProviderPageInfo` drops) is terminal-but-incomplete: `hasMore: false` + `page.truncated`.
398
+ // Synthesizing a page-number cursor for a cursor-only host instead — which is what this read used to do
399
+ // unconditionally — handed back a continuation the provider ignores, so it answered with its FIRST page
400
+ // again while still reporting `hasMore: true`. A consumer draining until `hasMore` clears (Kepler's repo
401
+ // drain does exactly that) then looped to its own page cap, accumulating a duplicate copy of every repo
402
+ // per round, and never saw a truncation signal.
403
+ const continuation = resolveContinuation(paged, pageAdvanceable ? currentPage + 1 : undefined);
404
+ // The org-hierarchy read can also stop at a defensive backstop with more repos unlisted and no cursor to
405
+ // resume (top-level `truncated`, or `paging.truncated` on a single-page read). Metadata incompleteness and
406
+ // a demoted continuation are two further, independent sources of the same signal.
407
+ const truncated =
408
+ (value?.truncated ?? false) ||
409
+ (value?.paging?.truncated ?? false) ||
410
+ assessment.truncated ||
411
+ continuation.truncated;
412
+ return {
413
+ // Normalize the raw provider-apis repos to the GitLens-owned shape at the surface boundary.
414
+ items: items.map(toProviderRepositoryShape),
415
+ warnings: warnings,
416
+ page: { ...paged.page, currentPage: currentPage, truncated: truncated || undefined },
417
+ hasMore: continuation.hasMore,
418
+ cursor: continuation.cursor,
419
+ fetchFailed: assessment.fetchFailed || (warning != null && value == null) || undefined,
420
+ };
439
421
  };
440
422
 
441
- // A cursor-only host ignores the synthesized page cursor above and returns page 1. Detect that from
442
- // the provider's own paging metadata, then advance through its real continuations. Numbered hosts
443
- // keep their one-request path because `pageAdvanceable` is true.
444
- if (options.cursor == null && page > 1 && !pageAdvanceable) {
445
- const traversalWarnings = [...result.warnings];
446
- let traversalFetchFailed = result.fetchFailed === true;
447
- let traversalTruncated = result.page.truncated === true;
448
- let previous = result;
449
-
450
- for (let currentPage = 2; currentPage <= page; currentPage++) {
451
- if (!previous.hasMore || previous.cursor == null) {
452
- return {
453
- items: [],
454
- warnings: traversalWarnings,
455
- page: {
456
- currentPage: page,
457
- itemsPerPage: 0,
458
- truncated: traversalTruncated || undefined,
459
- },
460
- hasMore: false,
461
- fetchFailed: traversalFetchFailed || undefined,
462
- };
463
- }
464
-
465
- const requested = await listRepos(ctx, {
466
- ...options,
467
- page: currentPage,
468
- cursor: previous.cursor,
469
- });
470
- for (const traversalWarning of requested.warnings) {
471
- appendDedupedWarning(traversalWarnings, traversalWarning);
472
- }
473
- traversalFetchFailed ||= requested.fetchFailed === true;
474
- traversalTruncated ||= requested.page.truncated === true;
423
+ const first = await readPage(options.cursor ?? pageToCursor(page));
424
+ const result = toResult(first, page, options.cursor);
425
+ // A numbered-page host honored the synthesized cursor, so this IS page N; a cursor-only host ignored it and
426
+ // answered with page 1, which the walk below advances. Decided from the provider's own paging metadata rather
427
+ // than assumed, hence after the read.
428
+ const pageAdvanceable = first.value?.paging?.page != null || first.value?.paging?.nextPage != null;
429
+ if (options.cursor != null || page === 1 || pageAdvanceable) return result;
475
430
 
476
- if (currentPage === page) {
477
- return {
478
- ...requested,
479
- warnings: traversalWarnings,
480
- page: {
481
- ...requested.page,
482
- truncated: traversalTruncated || undefined,
483
- },
484
- fetchFailed: traversalFetchFailed || undefined,
485
- };
486
- }
431
+ // Walk the provider's real continuations to the requested page. Only the last page's items are kept — pages
432
+ // 1..N returned as "page N" would duplicate items for a normal paged consumer — while warnings, truncation,
433
+ // and failure accumulate across the whole walked prefix, since an earlier round's problem still means this
434
+ // result isn't authoritative. The provider read is re-issued per round, but the integration is resolved once:
435
+ // re-entering this function per page would repeat its auth resolution N times for page N.
436
+ const walkedWarnings = [...result.warnings];
437
+ let walkedFetchFailed = result.fetchFailed === true;
438
+ let walkedTruncated = result.page.truncated === true;
439
+ let previous = result;
440
+ for (let currentPage = 2; currentPage <= page; currentPage++) {
441
+ if (!previous.hasMore || previous.cursor == null) {
442
+ // The requested page lies past the provider's terminal cursor: an EMPTY page N, never the last
443
+ // available page relabeled (see ProviderPageInfo.currentPage).
444
+ return {
445
+ items: [],
446
+ warnings: walkedWarnings,
447
+ page: { currentPage: page, itemsPerPage: 0, truncated: walkedTruncated || undefined },
448
+ hasMore: false,
449
+ fetchFailed: walkedFetchFailed || undefined,
450
+ };
451
+ }
487
452
 
488
- previous = requested;
453
+ const suppliedCursor = previous.cursor;
454
+ const requested = toResult(await readPage(suppliedCursor), currentPage, suppliedCursor);
455
+ for (const warning of requested.warnings) {
456
+ appendDedupedWarning(walkedWarnings, warning);
489
457
  }
458
+ walkedFetchFailed ||= requested.fetchFailed === true;
459
+ walkedTruncated ||= requested.page.truncated === true;
460
+ if (currentPage === page) {
461
+ return {
462
+ ...requested,
463
+ warnings: walkedWarnings,
464
+ page: { ...requested.page, truncated: walkedTruncated || undefined },
465
+ fetchFailed: walkedFetchFailed || undefined,
466
+ };
467
+ }
468
+
469
+ previous = requested;
490
470
  }
491
471
 
492
472
  return result;
@@ -1,7 +1,8 @@
1
1
  import type { IssueResourceDescriptor, ResourceDescriptor } from '../../../git/models/resourceDescriptor.js';
2
2
  import { IssuesCloudHostIntegrationId } from '../constants.js';
3
3
  import type { IntegrationIds } from '../constants.js';
4
- import type { ProviderOrganization } from '../results.js';
4
+ import type { ProviderOrganization, ProviderResult, ProviderWarning } from '../results.js';
5
+ import { appendDedupedWarning } from '../results.js';
5
6
 
6
7
  /**
7
8
  * Mapping of a provider's own hierarchy vocabulary onto the single {@link ProviderOrganization} shape the facade
@@ -30,6 +31,26 @@ export function resourceMatchesOrg(resource: ResourceDescriptor, org: string): b
30
31
  return resource.key === org || typed.id === org || typed.name === org;
31
32
  }
32
33
 
34
+ /**
35
+ * The id of the RESOURCE a project belongs to, used to look up which account (and therefore which user) that
36
+ * project's issues should be scoped to. Falls back through the project's own identifiers because some providers
37
+ * (and test doubles) return project descriptors without naming their parent.
38
+ */
39
+ export function resourceIdForProject(project: ResourceDescriptor): string | undefined {
40
+ const typed = project as IssueResourceDescriptor & { resourceId?: string };
41
+ return typed.resourceId ?? typed.id ?? project.key;
42
+ }
43
+
44
+ /**
45
+ * A stable identity for one project across facade pages, for the retry/completed bookkeeping an issue-tracker
46
+ * cursor carries. All three identifiers are included rather than one preferred value: two trackers can reuse the
47
+ * same project id, so keying on a single field would collapse them into one entry.
48
+ */
49
+ export function projectKey(project: ResourceDescriptor): string {
50
+ const typed = project as IssueResourceDescriptor & { resourceId?: string };
51
+ return JSON.stringify([typed.resourceId ?? '', typed.id ?? '', project.key]);
52
+ }
53
+
33
54
  /**
34
55
  * Maps an issue-tracker resource descriptor to the unified {@link ProviderOrganization} org shape. `url` is
35
56
  * synthesized when the descriptor carries none, rather than widening the shared `ProviderOrganization.url` to
@@ -90,6 +111,34 @@ export function withProviderContext(providerId: IntegrationIds, item: ProviderOr
90
111
  };
91
112
  }
92
113
 
114
+ /**
115
+ * Merges the per-provider slices of a hierarchy fan-out into one flat result.
116
+ *
117
+ * `ProviderResult` has no page object, so `fetchFailed` is its only non-authoritative signal — any slice that
118
+ * failed or came back incomplete has to set it, or a caller would treat a short list as the whole account.
119
+ * Warnings are deduped because the same account can surface under two ids in one fan-out.
120
+ */
121
+ export function mergeHierarchyResults<T>(
122
+ results: readonly ({ items: T[]; warnings: ProviderWarning[]; fetchFailed: boolean } | undefined)[],
123
+ ): ProviderResult<T> {
124
+ const items: T[] = [];
125
+ const warnings: ProviderWarning[] = [];
126
+ let fetchFailed = false;
127
+ for (const result of results) {
128
+ if (result == null) continue;
129
+
130
+ items.push(...result.items);
131
+ for (const warning of result.warnings) {
132
+ appendDedupedWarning(warnings, warning);
133
+ }
134
+ if (result.fetchFailed) {
135
+ fetchFailed = true;
136
+ }
137
+ }
138
+
139
+ return { items: items, warnings: warnings, fetchFailed: fetchFailed || undefined };
140
+ }
141
+
93
142
  /**
94
143
  * Rejects a hierarchy read whose scoped selectors have no provider to apply to. `connectionId` and `domain`
95
144
  * both select among the connections OF a provider, so either one without `providerId` is ambiguous rather than
@@ -3,7 +3,8 @@ import type { IssueShape } from '../../../git/models/issue.js';
3
3
  import type { ResourceDescriptor } from '../../../git/models/resourceDescriptor.js';
4
4
  import { mapBounded } from '../../../utils/promise.js';
5
5
  import { mergeAssessmentInto } from '../collectionMetadata.js';
6
- import { providerFanOutConcurrency, type IntegrationIds } from '../constants.js';
6
+ import type { IntegrationIds } from '../constants.js';
7
+ import { providerFanOutConcurrency } from '../constants.js';
7
8
  import { isIssuesIntegration } from '../models/issuesIntegration.js';
8
9
  import { IssueFilter, providersMetadata } from '../providers/models.js';
9
10
  import { mergeCollectionMetadata, parsePageCursor } from '../providers/utils/providerPaging.js';
@@ -12,7 +13,7 @@ import { isIssuesHostIntegrationId } from '../utils/integration.utils.js';
12
13
  import type { ProviderReadContext } from './context.js';
13
14
  import { parseIssueTrackerPageCursor, toIssueTrackerPageCursor } from './cursors.js';
14
15
  import { runCaptured } from './drains.js';
15
- import { resourceMatchesOrg } from './hierarchy.utils.js';
16
+ import { projectKey, resourceIdForProject, resourceLabel, resourceMatchesOrg } from './hierarchy.utils.js';
16
17
  import { issueTrackerOnlySurfaceWarning, otherWarning } from './warnings.js';
17
18
 
18
19
  export async function listIssueTrackerIssuesPage(
@@ -219,19 +220,6 @@ export async function listIssueTrackerIssuesPage(
219
220
  return emptyPage(true);
220
221
  }
221
222
 
222
- const resourceIdForProject = (project: ResourceDescriptor): string | undefined => {
223
- const issueProject = project as { id?: string; key: string; resourceId?: string };
224
- return issueProject.resourceId ?? issueProject.id ?? issueProject.key;
225
- };
226
- const retryKeyForProject = (project: ResourceDescriptor): string => {
227
- const issueProject = project as { id?: string; key: string; resourceId?: string };
228
- return JSON.stringify([issueProject.resourceId ?? '', issueProject.id ?? '', issueProject.key]);
229
- };
230
- const labelForResource = (resource: ResourceDescriptor): string => {
231
- const issueResource = resource as { id?: string; key: string; name?: string };
232
- return issueResource.name ?? issueResource.id ?? issueResource.key;
233
- };
234
-
235
223
  // Scope to the current user's assigned issues unless the caller broadens to all assignees. Resolve the
236
224
  // handle from each resource's own account (multi-account safe), capturing any error so its kind
237
225
  // (e.g. auth) is preserved rather than collapsed to a generic warning.
@@ -259,7 +247,7 @@ export async function listIssueTrackerIssuesPage(
259
247
  options.providerId,
260
248
  domain,
261
249
  options.connectionId,
262
- `Could not resolve the current user for '${labelForResource(resource)}'; skipping that resource to avoid returning issues assigned to others.`,
250
+ `Could not resolve the current user for '${resourceLabel(resource)}'; skipping that resource to avoid returning issues assigned to others.`,
263
251
  ),
264
252
  );
265
253
  accountLookupFailed = true;
@@ -294,22 +282,22 @@ export async function listIssueTrackerIssuesPage(
294
282
  ? matchedProjects.filter(project => {
295
283
  if (userForProject(project) != null) return true;
296
284
 
297
- const projectKey = retryKeyForProject(project);
298
- retryProjectKeys.add(projectKey);
299
- completedProjectKeys.delete(projectKey);
285
+ const key = projectKey(project);
286
+ retryProjectKeys.add(key);
287
+ completedProjectKeys.delete(key);
300
288
  return false;
301
289
  })
302
290
  : matchedProjects;
303
291
 
304
292
  // Select the untouched window plus any earlier windows/projects that explicitly need retrying. Keys
305
293
  // include the parent resource so two trackers can reuse the same project id without collapsing.
306
- const projectsByRetryKey = new Map(scopedProjectsWithUsers.map(project => [retryKeyForProject(project), project]));
294
+ const projectsByRetryKey = new Map(scopedProjectsWithUsers.map(project => [projectKey(project), project]));
307
295
  const scopedProjectsByKey = new Map<string, ResourceDescriptor>();
308
296
  const addScopedProject = (project: ResourceDescriptor): void => {
309
- const projectKey = retryKeyForProject(project);
310
- if (completedProjectKeys.has(projectKey)) return;
297
+ const key = projectKey(project);
298
+ if (completedProjectKeys.has(key)) return;
311
299
 
312
- scopedProjectsByKey.set(projectKey, project);
300
+ scopedProjectsByKey.set(key, project);
313
301
  };
314
302
  for (const retryProject of priorRetryProjects) {
315
303
  const project = projectsByRetryKey.get(retryProject);
@@ -396,7 +384,7 @@ export async function listIssueTrackerIssuesPage(
396
384
  let projectTruncated = projectDiscoveryTruncated;
397
385
  let drainMetadata: CollectionMetadata | undefined;
398
386
  for (const { project, value: result, warning } of perProject) {
399
- const projectKey = retryKeyForProject(project);
387
+ const key = projectKey(project);
400
388
  const retryProject = result == null;
401
389
  if (warning != null) {
402
390
  warnings.push(warning);
@@ -418,13 +406,13 @@ export async function listIssueTrackerIssuesPage(
418
406
  }
419
407
  }
420
408
  if (retryProject) {
421
- retryProjectKeys.add(projectKey);
422
- completedProjectKeys.delete(projectKey);
409
+ retryProjectKeys.add(key);
410
+ completedProjectKeys.delete(key);
423
411
  } else {
424
412
  // A usable partial/truncated project is emitted once and remains structurally incomplete.
425
413
  // Re-running the same project cursor would normally return the same prefix and duplicate it
426
414
  // across facade pages; only a project that returned no value is safe to retry automatically.
427
- completedProjectKeys.add(projectKey);
415
+ completedProjectKeys.add(key);
428
416
  }
429
417
  }
430
418
 
@@ -1,7 +1,9 @@
1
1
  import type { IssueShape } from '../../../git/models/issue.js';
2
2
  import { mergeAssessmentInto } from '../collectionMetadata.js';
3
- import { GitCloudHostIntegrationId, GitSelfManagedHostIntegrationId, type IntegrationIds } from '../constants.js';
4
- import { IssueFilter, PagingMode, providersMetadata, type ProviderReposInput } from '../providers/models.js';
3
+ import type { IntegrationIds } from '../constants.js';
4
+ import { GitCloudHostIntegrationId, GitSelfManagedHostIntegrationId } from '../constants.js';
5
+ import type { IssueFilter, ProviderReposInput } from '../providers/models.js';
6
+ import { PagingMode, providersMetadata } from '../providers/models.js';
5
7
  import { mergeCollectionMetadata } from '../providers/utils/providerPaging.js';
6
8
  import type { ProviderPagedResult, ProviderWarning } from '../results.js';
7
9
  import { appendDedupedWarning } from '../results.js';
@@ -1,15 +1,8 @@
1
1
  import type { PullRequestShape, PullRequestStateFilter } from '../../../git/models/pullRequest.js';
2
2
  import { mergeAssessmentInto } from '../collectionMetadata.js';
3
3
  import type { IntegrationIds } from '../constants.js';
4
- import type { GitHostIntegration } from '../models/gitHostIntegration.js';
5
- import {
6
- fromProviderPullRequest,
7
- PagingMode,
8
- providersMetadata,
9
- type ProviderPullRequest,
10
- type ProviderReposInput,
11
- type PullRequestFilter,
12
- } from '../providers/models.js';
4
+ import type { ProviderPullRequest, ProviderReposInput, PullRequestFilter } from '../providers/models.js';
5
+ import { fromProviderPullRequest, PagingMode, providersMetadata } from '../providers/models.js';
13
6
  import type { ProviderPagedResult } from '../results.js';
14
7
  import {
15
8
  isGitHostIntegration,