@gitkraken/core-gitlens 0.5.101 → 0.5.103

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 (46) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/plus/integrations/collectionMetadata.d.ts +16 -10
  3. package/dist/plus/integrations/collectionMetadata.d.ts.map +1 -1
  4. package/dist/plus/integrations/collectionMetadata.js +64 -16
  5. package/dist/plus/integrations/collectionMetadata.js.map +1 -1
  6. package/dist/plus/integrations/index.d.ts +1 -1
  7. package/dist/plus/integrations/index.d.ts.map +1 -1
  8. package/dist/plus/integrations/index.js.map +1 -1
  9. package/dist/plus/integrations/providers/utils/providerPaging.d.ts.map +1 -1
  10. package/dist/plus/integrations/providers/utils/providerPaging.js +2 -1
  11. package/dist/plus/integrations/providers/utils/providerPaging.js.map +1 -1
  12. package/dist/plus/integrations/reads/drains.d.ts.map +1 -1
  13. package/dist/plus/integrations/reads/drains.js +59 -20
  14. package/dist/plus/integrations/reads/drains.js.map +1 -1
  15. package/dist/plus/integrations/reads/hierarchy.d.ts.map +1 -1
  16. package/dist/plus/integrations/reads/hierarchy.js +34 -17
  17. package/dist/plus/integrations/reads/hierarchy.js.map +1 -1
  18. package/dist/plus/integrations/reads/issueTracker.d.ts.map +1 -1
  19. package/dist/plus/integrations/reads/issueTracker.js +8 -2
  20. package/dist/plus/integrations/reads/issueTracker.js.map +1 -1
  21. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  22. package/dist/plus/integrations/reads/issues.js +13 -3
  23. package/dist/plus/integrations/reads/issues.js.map +1 -1
  24. package/dist/plus/integrations/reads/pullRequests.d.ts.map +1 -1
  25. package/dist/plus/integrations/reads/pullRequests.js +7 -1
  26. package/dist/plus/integrations/reads/pullRequests.js.map +1 -1
  27. package/dist/plus/integrations/reads/warnings.d.ts +43 -4
  28. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  29. package/dist/plus/integrations/reads/warnings.js +47 -5
  30. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  31. package/dist/plus/integrations/results.d.ts +115 -1
  32. package/dist/plus/integrations/results.d.ts.map +1 -1
  33. package/dist/plus/integrations/results.js +78 -3
  34. package/dist/plus/integrations/results.js.map +1 -1
  35. package/docs/integrations.md +81 -7
  36. package/package.json +1 -1
  37. package/src/plus/integrations/collectionMetadata.ts +67 -21
  38. package/src/plus/integrations/index.ts +4 -0
  39. package/src/plus/integrations/providers/utils/providerPaging.ts +2 -1
  40. package/src/plus/integrations/reads/drains.ts +84 -20
  41. package/src/plus/integrations/reads/hierarchy.ts +43 -32
  42. package/src/plus/integrations/reads/issueTracker.ts +8 -2
  43. package/src/plus/integrations/reads/issues.ts +21 -4
  44. package/src/plus/integrations/reads/pullRequests.ts +15 -1
  45. package/src/plus/integrations/reads/warnings.ts +86 -10
  46. package/src/plus/integrations/results.ts +173 -4
@@ -9,7 +9,7 @@ import type { PullRequestFilter } from '../providerFilters.js';
9
9
  import type { ProviderPullRequest, ProviderReposInput, ProviderRepository } from '../providers/models.js';
10
10
  import { getProviderPullRequestIdentity } from '../providers/models.js';
11
11
  import type { ProviderWarning } from '../results.js';
12
- import { appendDedupedWarning, toProviderWarning } from '../results.js';
12
+ import { appendDedupedWarning, reconcileOmissionsWithFailure, toProviderWarning } from '../results.js';
13
13
  import { isIssuesHostIntegrationId } from '../utils/integration.utils.js';
14
14
  import { noConnectionWarning, truncationWarning } from './warnings.js';
15
15
 
@@ -90,10 +90,18 @@ export async function drainPullRequests(
90
90
  // this through the terminal returns instead of resetting it to false at the last page.
91
91
  let fetchFailed = false;
92
92
  let truncated = false;
93
+ // A page the provider capped or couldn't vouch for. Decides the CAUSE the terminal warning reports: a cap
94
+ // outranks a budget stop, because raising a budget cannot un-cap a page.
95
+ let providerTruncated = false;
96
+ // The subset of that which no other warning already explains — decides whether this drain raises one of
97
+ // its own on an otherwise clean exit.
98
+ let unexplainedTruncation = false;
93
99
 
94
100
  // With no repos this is an account-wide "my PRs" sweep. The repo-scoped core rejects an empty `repos`
95
101
  // input, so read the provider-native account-wide core instead.
96
102
  const accountWide = repos.length === 0;
103
+ /** Every cursor already followed, so a provider that cycles them can't keep the drain walking in circles. */
104
+ const seenCursors = new Set<string>();
97
105
 
98
106
  for (;;) {
99
107
  page++;
@@ -130,10 +138,23 @@ export async function drainPullRequests(
130
138
  appendDedupedWarning(warnings, noConnectionWarning(id, domain, connectionId));
131
139
  }
132
140
  // `warning` set → a hard read failure (incomplete items); otherwise not connected / no session.
141
+ const failed = fetchFailed || warning != null || unavailable;
142
+ // A cap seen on an earlier page still left results out, so it is still worth saying — but as part
143
+ // of a read that failed, never as an omission. (`failed` is always true here when anything was
144
+ // latched: reaching this exit past page 1 means a later page was lost.)
145
+ if (unexplainedTruncation) {
146
+ appendDedupedWarning(
147
+ warnings,
148
+ truncationWarning(id, domain, connectionId, 'Pull request', failed ? 'interrupted' : 'exhausted'),
149
+ );
150
+ }
151
+ // An earlier page may already have emitted an omission before this one died; it asserts the read
152
+ // succeeded, which is no longer true.
153
+ reconcileOmissionsWithFailure(warnings, failed);
133
154
  return {
134
155
  items: items,
135
156
  warnings: warnings,
136
- fetchFailed: fetchFailed || warning != null || unavailable,
157
+ fetchFailed: failed,
137
158
  truncated: truncated || sessionLostAfterProgress,
138
159
  // Only a top-level first-page rejection means the provider itself failed. A later-page or
139
160
  // per-scope failure still yielded a usable provider slice and stays represented separately.
@@ -171,14 +192,31 @@ export async function drainPullRequests(
171
192
  value.paging?.truncated === true ||
172
193
  assessment.truncated;
173
194
  truncated = truncated || pageTruncated;
174
- if (pageTruncated && !assessment.truncated) {
175
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Pull request'));
176
- }
195
+ // A page the provider itself capped, or one whose completeness it couldn't confirm. Latched from ANY
196
+ // source, SDK metadata included: no budget of ours un-caps a page, so a later `maxPages` hit must not
197
+ // claim raising it would help.
198
+ providerTruncated = providerTruncated || pageTruncated;
199
+ // Whether this drain owes its OWN warning for that is a separate question — `mergeAssessmentInto` has
200
+ // already appended one when the fact came from SDK metadata, and repeating it would be noise.
201
+ unexplainedTruncation = unexplainedTruncation || (pageTruncated && !assessment.truncated);
177
202
 
178
203
  if (!(value.paging?.more ?? false)) {
179
204
  // A read that can't confirm completeness (single-page provider reads with no `hasNextPage`)
180
205
  // sets `paging.truncated`; propagate it (and any top-level `truncated` and SDK incompleteness)
181
206
  // so the sweep doesn't claim an all-pages result.
207
+ if (unexplainedTruncation) {
208
+ appendDedupedWarning(
209
+ warnings,
210
+ truncationWarning(
211
+ id,
212
+ domain,
213
+ connectionId,
214
+ 'Pull request',
215
+ fetchFailed ? 'interrupted' : 'exhausted',
216
+ ),
217
+ );
218
+ }
219
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
182
220
  return {
183
221
  items: items,
184
222
  warnings: warnings,
@@ -187,21 +225,34 @@ export async function drainPullRequests(
187
225
  failedProvider: false,
188
226
  };
189
227
  }
190
- if (page >= maxPages) {
191
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Pull request'));
192
- return {
193
- items: items,
194
- warnings: warnings,
195
- fetchFailed: fetchFailed,
196
- truncated: true,
197
- failedProvider: false,
198
- };
199
- }
200
228
 
229
+ // Resolve the continuation BEFORE deciding why the drain stops. `page-budget` claims the missing items
230
+ // are reachable, which is only true with a usable cursor in hand — and a provider can report another
231
+ // page while handing back none (Bitbucket Server does, when it omits `nextPageStart`). Checking the
232
+ // budget first would label that unreachable tail as merely unfetched, so raising `maxPages` would
233
+ // return the identical set.
201
234
  const nextCursor = value.paging?.cursor;
202
- if (nextCursor == null || nextCursor === '{}') {
203
- // Provider says there is more but didn't return a usable cursor; stop rather than refetch the same page.
204
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Pull request'));
235
+ // A cursor already used isn't a continuation either — following it refetches a page we have, and a
236
+ // provider that cycles (A→B→A) would otherwise burn the whole budget and then be reported as merely
237
+ // out of budget. Tracked as a SET rather than compared one-back, matching the SDK's own `followCursors`:
238
+ // `drainToRequestedPage` and `collectProviderPagedResult` compare only the previous cursor, which a
239
+ // cycle slips past. That is tolerable there and not here, because only this drain reports `page-budget`.
240
+ const continuable = nextCursor != null && nextCursor !== '{}' && !seenCursors.has(nextCursor);
241
+ if (!continuable || page >= maxPages) {
242
+ // `providerTruncated` outranks the budget: a page the provider capped stays capped however many
243
+ // pages we are allowed to read, so promising `page-budget` on top of it would be a load-more that
244
+ // cannot deliver the capped part.
245
+ appendDedupedWarning(
246
+ warnings,
247
+ truncationWarning(
248
+ id,
249
+ domain,
250
+ connectionId,
251
+ 'Pull request',
252
+ fetchFailed ? 'interrupted' : continuable && !providerTruncated ? 'page-budget' : 'exhausted',
253
+ ),
254
+ );
255
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
205
256
  return {
206
257
  items: items,
207
258
  warnings: warnings,
@@ -211,6 +262,7 @@ export async function drainPullRequests(
211
262
  };
212
263
  }
213
264
 
265
+ seenCursors.add(nextCursor);
214
266
  cursor = nextCursor;
215
267
  }
216
268
  }
@@ -262,12 +314,20 @@ export async function drainRepositories(
262
314
  if (value == null) {
263
315
  const interruptedAfterProgress = page > 1;
264
316
  if (interruptedAfterProgress && warning == null) {
265
- appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, 'Repository'));
317
+ // Not a backstop: the read was cut short mid-drain, which is why it also sets `fetchFailed`
318
+ // below. A retry may complete it, so this must not claim the succeeded-but-capped omission.
319
+ appendDedupedWarning(
320
+ warnings,
321
+ truncationWarning(id, domain, connectionId, 'Repository', 'interrupted'),
322
+ );
266
323
  }
324
+ const failed = fetchFailed || warning != null || interruptedAfterProgress;
325
+ // An SDK omission from an earlier page asserts the read succeeded; this one didn't.
326
+ reconcileOmissionsWithFailure(warnings, failed);
267
327
  return {
268
328
  repos: repos,
269
329
  warnings: warnings,
270
- fetchFailed: fetchFailed || warning != null || interruptedAfterProgress,
330
+ fetchFailed: failed,
271
331
  truncated: truncated || interruptedAfterProgress,
272
332
  };
273
333
  }
@@ -277,6 +337,7 @@ export async function drainRepositories(
277
337
  fetchFailed = fetchFailed || assessment.fetchFailed;
278
338
  truncated = truncated || value.truncated === true || value.paging?.truncated === true || assessment.truncated;
279
339
  if (!(value.paging?.more ?? false)) {
340
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
280
341
  return {
281
342
  repos: repos,
282
343
  warnings: warnings,
@@ -284,13 +345,16 @@ export async function drainRepositories(
284
345
  truncated: truncated,
285
346
  };
286
347
  }
348
+
287
349
  if (page >= maxPages) {
350
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
288
351
  return { repos: repos, warnings: warnings, fetchFailed: fetchFailed, truncated: true };
289
352
  }
290
353
 
291
354
  const nextCursor = value.paging?.cursor;
292
355
  if (nextCursor == null || nextCursor === '{}') {
293
356
  // Provider says there is more but didn't return a usable cursor; stop rather than refetch the same page.
357
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
294
358
  return { repos: repos, warnings: warnings, fetchFailed: fetchFailed, truncated: true };
295
359
  }
296
360
 
@@ -10,6 +10,7 @@ import {
10
10
  } from '../constants.js';
11
11
  import type { ListOrgsOptions, ListProjectsOptions } from '../manager.js';
12
12
  import { isIssuesIntegration } from '../models/issuesIntegration.js';
13
+ import type { ProviderHierarchyResult } from '../providers/models.js';
13
14
  import { toProviderRepositoryShape } from '../providers/models.js';
14
15
  import type {
15
16
  ProviderOrganization,
@@ -35,7 +36,46 @@ import {
35
36
  withProviderContext,
36
37
  } from './hierarchy.utils.js';
37
38
  import { pageToCursor, refusedPage, resolveContinuation, resolveCurrentPage, toProviderPageInfo } from './paging.js';
38
- import { gitHostOnlySurfaceWarning, otherWarning } from './warnings.js';
39
+ import { gitHostOnlySurfaceWarning, otherWarning, truncationWarning } from './warnings.js';
40
+
41
+ /**
42
+ * Folds a flat hierarchy result's incompleteness into `warnings`, returning whether it leaves the read
43
+ * non-authoritative. The single funnel for both hierarchy reads, so neither can drift from the other on any of
44
+ * the four decisions it makes:
45
+ *
46
+ * - The metadata is assessed FIRST, so the truncation warning can tell whether it already explained this gap.
47
+ * Ordered deliberately: emitting first meant an expired credential produced BOTH a typed `auth` warning and
48
+ * an unclassifiable one for the same cause, and a consumer routing on classification showed the second as
49
+ * "this provider failed to load", with a remedy that doesn't exist, next to the reconnect prompt that was the
50
+ * actual fix.
51
+ * - The read states the truncation itself only when the metadata reported nothing — the rule
52
+ * `assessCollectionMetadata` applies to its own generic fallback. Whatever the metadata reported already names
53
+ * this provider and says something stronger, so restating it costs a false verdict and adds no information.
54
+ * - `'exhausted'`, never `'page-budget'`: `truncated` is one boolean here, so a drain that stopped at its page
55
+ * budget is indistinguishable from one whose cursor stalled, and `IncompleteReadCause` makes `'exhausted'`
56
+ * the default whenever a raisable budget is not demonstrably the cause.
57
+ * - A truncation counts as non-authoritative even though the omission above asserts the read succeeded. Unlike
58
+ * paged repository reads, a flattened hierarchy result has no page object on which to carry incompleteness,
59
+ * so `fetchFailed` is the only signal a consumer has that the list is short.
60
+ */
61
+ function mergeHierarchyIncompleteness(
62
+ warnings: ProviderWarning[],
63
+ id: IntegrationIds,
64
+ domain: string | undefined,
65
+ connectionId: string | undefined,
66
+ result: Pick<ProviderHierarchyResult<unknown>, 'truncated' | 'metadata'>,
67
+ readKind: 'Organization' | 'Project',
68
+ ): boolean {
69
+ const assessment = mergeAssessmentInto(warnings, id, domain, connectionId, result.metadata);
70
+ if (!result.truncated) return assessment.fetchFailed || assessment.truncated;
71
+
72
+ if (!assessment.reported) {
73
+ // Deduped like every other `truncationWarning` emission (see `drains.ts`), not plain-pushed: no caller can
74
+ // collide with it today, but nothing about this funnel guarantees a third read won't.
75
+ appendDedupedWarning(warnings, truncationWarning(id, domain, connectionId, readKind, 'exhausted'));
76
+ }
77
+ return true;
78
+ }
39
79
 
40
80
  export async function listOrgs(
41
81
  ctx: ProviderReadContext,
@@ -103,22 +143,8 @@ export async function listOrgs(
103
143
  );
104
144
  if (value != null) {
105
145
  items.push(...value.values.map(org => withProviderContext(id, org)));
106
- if (value.truncated) {
107
- warnings.push(
108
- otherWarning(
109
- id,
110
- domain,
111
- connectionId,
112
- 'Organization listing was truncated before the upstream results were exhausted.',
113
- ),
114
- );
115
- // `ProviderResult` has no page object on which to carry truncation. Mark the flat
116
- // hierarchy result incomplete so consumers don't treat omitted orgs as authoritative.
117
- fetchFailed = true;
118
- }
119
146
 
120
- const assessment = mergeAssessmentInto(warnings, id, domain, connectionId, value.metadata);
121
- if (assessment.fetchFailed || assessment.truncated) {
147
+ if (mergeHierarchyIncompleteness(warnings, id, domain, connectionId, value, 'Organization')) {
122
148
  fetchFailed = true;
123
149
  }
124
150
  }
@@ -200,22 +226,7 @@ export async function listProjects(
200
226
  if (projects != null) {
201
227
  items.push(...projects.values.map(project => withProviderContext(id, project)));
202
228
 
203
- if (projects.truncated) {
204
- warnings.push(
205
- otherWarning(
206
- id,
207
- domain,
208
- connectionId,
209
- 'Project listing was truncated before the upstream results were exhausted.',
210
- ),
211
- );
212
- // Unlike paged repository reads, this flattened hierarchy result has no continuation
213
- // or page metadata. `fetchFailed` is its structural non-authoritative signal.
214
- fetchFailed = true;
215
- }
216
-
217
- const assessment = mergeAssessmentInto(warnings, id, domain, connectionId, projects.metadata);
218
- if (assessment.fetchFailed || assessment.truncated) {
229
+ if (mergeHierarchyIncompleteness(warnings, id, domain, connectionId, projects, 'Project')) {
219
230
  fetchFailed = true;
220
231
  }
221
232
  }
@@ -9,12 +9,13 @@ import { isIssuesIntegration } from '../models/issuesIntegration.js';
9
9
  import { IssueFilter, providersMetadata } from '../providers/models.js';
10
10
  import { mergeCollectionMetadata, parsePageCursor } from '../providers/utils/providerPaging.js';
11
11
  import type { ProviderPagedResult, ProviderWarning } from '../results.js';
12
+ import { reconcileOmissionsWithFailure } from '../results.js';
12
13
  import { isIssuesHostIntegrationId } from '../utils/integration.utils.js';
13
14
  import type { ProviderReadContext } from './context.js';
14
15
  import { parseIssueTrackerPageCursor, toIssueTrackerPageCursor } from './cursors.js';
15
16
  import { runCaptured } from './drains.js';
16
17
  import { projectKey, resourceIdForProject, resourceLabel, resourceMatchesOrg } from './hierarchy.utils.js';
17
- import { issueTrackerOnlySurfaceWarning, otherWarning } from './warnings.js';
18
+ import { incompleteReadWarning, issueTrackerOnlySurfaceWarning, otherWarning } from './warnings.js';
18
19
 
19
20
  export async function listIssueTrackerIssuesPage(
20
21
  ctx: ProviderReadContext,
@@ -431,14 +432,19 @@ export async function listIssueTrackerIssuesPage(
431
432
  // the caller sees the truncation, but only when no warning already explains it (avoid duplicate noise).
432
433
  if (projectTruncated && warnings.length === 0) {
433
434
  warnings.push(
434
- otherWarning(
435
+ incompleteReadWarning(
435
436
  options.providerId,
436
437
  domain,
437
438
  options.connectionId,
438
439
  'Some issues were omitted; the provider returned an incomplete result.',
440
+ // `exhausted`, not `page-budget`: the per-project drain's backstop is an internal constant
441
+ // (`maxPagesPerRequest`), not an option this read exposes, so no caller can raise it.
442
+ fetchFailed ? 'interrupted' : 'exhausted',
439
443
  ),
440
444
  );
441
445
  }
446
+ // A metadata omission from an earlier project asserts the read succeeded; a later one may since have failed.
447
+ reconcileOmissionsWithFailure(warnings, fetchFailed);
442
448
 
443
449
  const retryPages = retryWindowPages();
444
450
  const cursor = toIssueTrackerPageCursor({
@@ -6,7 +6,7 @@ import type { IssueFilter, ProviderReposInput } from '../providers/models.js';
6
6
  import { PagingMode, providersMetadata } from '../providers/models.js';
7
7
  import { mergeCollectionMetadata } from '../providers/utils/providerPaging.js';
8
8
  import type { ProviderPagedResult, ProviderWarning } from '../results.js';
9
- import { appendDedupedWarning } from '../results.js';
9
+ import { appendDedupedWarning, reconcileOmissionsWithFailure } from '../results.js';
10
10
  import {
11
11
  isGitHostIntegration,
12
12
  isIssuesHostIntegrationId,
@@ -269,14 +269,19 @@ export async function listIssuesPage(
269
269
  const truncated = continuation.truncated || assessment.truncated;
270
270
  if (truncated && warnings.length === 0) {
271
271
  warnings.push(
272
- otherWarning(
272
+ truncationWarning(
273
273
  options.providerId,
274
274
  domain,
275
275
  options.connectionId,
276
- `Account-wide issue search for '${options.providerId}' was truncated; results may be incomplete.`,
276
+ 'Account-wide issue search',
277
+ // `exhausted`: this composite read exposes no budget the caller can raise, so nothing it
278
+ // could call would return the withheld items.
279
+ assessment.fetchFailed || pageFetchFailed ? 'interrupted' : 'exhausted',
277
280
  ),
278
281
  );
279
282
  }
283
+ // A metadata omission from an earlier page asserts the read succeeded; a later page may since have failed.
284
+ reconcileOmissionsWithFailure(warnings, assessment.fetchFailed || pageFetchFailed);
280
285
  return {
281
286
  items: items,
282
287
  warnings: warnings,
@@ -385,8 +390,20 @@ export async function listIssuesPage(
385
390
  // page isn't published as complete. Metadata incompleteness is an independent source of the same signal.
386
391
  const truncated = continuation.truncated || assessment.truncated;
387
392
  if (truncated && warnings.length === 0) {
388
- warnings.push(truncationWarning(options.providerId, domain, options.connectionId, 'Issue'));
393
+ warnings.push(
394
+ truncationWarning(
395
+ options.providerId,
396
+ domain,
397
+ options.connectionId,
398
+ 'Issue',
399
+ // `exhausted`, never `page-budget`: a paged read has no budget the caller can raise, and
400
+ // ordinary continuation is already expressed by `hasMore`/`cursor`.
401
+ assessment.fetchFailed || pageFetchFailed ? 'interrupted' : 'exhausted',
402
+ ),
403
+ );
389
404
  }
405
+ // A metadata omission from an earlier page asserts the read succeeded; a later page may since have failed.
406
+ reconcileOmissionsWithFailure(warnings, assessment.fetchFailed || pageFetchFailed);
390
407
  return {
391
408
  items: items,
392
409
  warnings: warnings,
@@ -4,6 +4,7 @@ import type { IntegrationIds } from '../constants.js';
4
4
  import type { ProviderPullRequest, ProviderReposInput, PullRequestFilter } from '../providers/models.js';
5
5
  import { fromProviderPullRequest, PagingMode, providersMetadata } from '../providers/models.js';
6
6
  import type { ProviderPagedResult } from '../results.js';
7
+ import { reconcileOmissionsWithFailure } from '../results.js';
7
8
  import {
8
9
  isGitHostIntegration,
9
10
  isIssuesHostIntegrationId,
@@ -226,8 +227,21 @@ export async function listPullRequestsPage(
226
227
  // the page may be incomplete. Metadata incompleteness is an independent source of the same signal.
227
228
  const truncated = continuation.truncated || assessment.truncated;
228
229
  if (truncated && warnings.length === 0) {
229
- warnings.push(truncationWarning(options.providerId, domain, options.connectionId, 'Pull request'));
230
+ // `exhausted`, never `page-budget`: a paged read has no budget the caller can raise. Ordinary
231
+ // continuation is already expressed by `hasMore`/`cursor`, so reaching here means this page itself
232
+ // couldn't be completed — raising anything would return the same page.
233
+ warnings.push(
234
+ truncationWarning(
235
+ options.providerId,
236
+ domain,
237
+ options.connectionId,
238
+ 'Pull request',
239
+ assessment.fetchFailed || pageFetchFailed ? 'interrupted' : 'exhausted',
240
+ ),
241
+ );
230
242
  }
243
+ // A metadata omission from an earlier page asserts the read succeeded; a later page may since have failed.
244
+ reconcileOmissionsWithFailure(warnings, assessment.fetchFailed || pageFetchFailed);
231
245
  const currentAccountId = items.some(pr => pr.author != null)
232
246
  ? await getCurrentAccountId(integration, options.connectionId)
233
247
  : undefined;
@@ -93,22 +93,98 @@ export function issueTrackerOnlySurfaceWarning(
93
93
  }
94
94
 
95
95
  /**
96
- * Builds a warning for a drain that stopped short of completeness (hit a page backstop, or a single-page read
97
- * that couldn't confirm it drained everything). `truncated`/`allPages` already carry this on the result, but
98
- * consumers that only inspect `warnings` would otherwise see no signal the read is partial.
96
+ * Why a read the facade drove itself returned less than everything. The two questions a consumer cannot
97
+ * answer from `truncated` or from the message, kept together because they are decided together:
98
+ *
99
+ * - `interrupted`: the read did NOT succeed — the session went away, a later page failed. It leaves an unread
100
+ * tail like the others, but it is a failure: it sets `fetchFailed`, and a retry is exactly the right move.
101
+ * Gets no omission at all, since the omission asserts the opposite.
102
+ * - `page-budget`: the drain stopped at its own `maxPages`, with a usable cursor still in hand. The items ARE
103
+ * reachable and were simply not fetched, so re-running with a higher budget returns them.
104
+ * - `exhausted`: everything else that succeeded and came back short — the provider capped the page it served,
105
+ * advertised another page without a usable cursor (or with one it had already handed out), or a read could
106
+ * not confirm it had drained everything. The default of the three: choose it whenever a raisable budget is
107
+ * not demonstrably what stopped the read, so a consumer is never offered a fetch that cannot deliver.
99
108
  */
109
+ export type IncompleteReadCause = 'interrupted' | 'page-budget' | 'exhausted';
110
+
111
+ /**
112
+ * Builds the warning for a read that returned less than everything, carrying the two facts a consumer cannot
113
+ * recover on its own: did the request succeed, and would anything fetch the rest?
114
+ *
115
+ * Every incompleteness warning the facade raises on its own terms goes through here, so no read can drift
116
+ * into claiming success on a failure or offering a "load more" that cannot deliver. The omission it attaches
117
+ * is the same shape `assessCollectionMetadata` derives from SDK metadata, so a consumer never has to care
118
+ * which layer noticed the gap.
119
+ *
120
+ * No `scope`: this applies to the whole read, not to one repository or project the way an SDK-attributed
121
+ * omission can, so a consumer acting on `recovery` re-runs the read itself.
122
+ */
123
+ export function incompleteReadWarning(
124
+ id: IntegrationIds,
125
+ domain: string | undefined,
126
+ connectionId: string | undefined,
127
+ message: string,
128
+ cause: IncompleteReadCause,
129
+ ): ProviderWarning {
130
+ const warning = otherWarning(id, domain, connectionId, message);
131
+ if (cause === 'interrupted') return warning;
132
+
133
+ return {
134
+ ...warning,
135
+ omission: {
136
+ kind: 'pagination-incomplete',
137
+ recovery: cause === 'page-budget' ? 'page-budget' : 'none',
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * The read surfaces {@link truncationWarning} phrases for. `'Account-wide issue search'` names the composite
144
+ * read that spans several provider searches rather than one surface.
145
+ *
146
+ * Deliberately not exported: every caller passes a literal, and publishing it would invite a consumer to switch
147
+ * on a set that exists only to word a message.
148
+ */
149
+ type TruncatedReadKind =
150
+ | 'Pull request'
151
+ | 'Issue'
152
+ | 'Repository'
153
+ | 'Organization'
154
+ | 'Project'
155
+ | 'Account-wide issue search';
156
+
157
+ /** {@link incompleteReadWarning} for a paged or drained read, phrased per surface. */
100
158
  export function truncationWarning(
101
159
  id: IntegrationIds,
102
160
  domain: string | undefined,
103
161
  connectionId: string | undefined,
104
- readKind: 'Pull request' | 'Issue' | 'Repository',
162
+ readKind: TruncatedReadKind,
163
+ /**
164
+ * Deliberately REQUIRED and not defaulted: a default would make one of these the silent fallback at any
165
+ * call site that forgot it, and both of the claims it carries — "the request succeeded" and "more can be
166
+ * fetched" — are ones this warning must never make by accident.
167
+ */
168
+ cause: IncompleteReadCause,
105
169
  ): ProviderWarning {
106
- return otherWarning(
107
- id,
108
- domain,
109
- connectionId,
110
- `${readKind} read for '${id}' was truncated (a page backstop was reached); results may be incomplete.`,
111
- );
170
+ return incompleteReadWarning(id, domain, connectionId, truncationMessage(id, readKind, cause), cause);
171
+ }
172
+
173
+ function truncationMessage(id: IntegrationIds, readKind: TruncatedReadKind, cause: IncompleteReadCause): string {
174
+ switch (cause) {
175
+ case 'interrupted':
176
+ // Deliberately does not name a mechanism. This fires both when a page was lost mid-drain and when a
177
+ // drain that latched an earlier scope failure later stopped for its own reasons; "was interrupted"
178
+ // would be false in the second. What is true in both is that something failed and results are gone.
179
+ return `${readKind} read for '${id}' did not complete; some results are missing and the read reported a failure.`;
180
+ case 'page-budget':
181
+ return `${readKind} read for '${id}' stopped at its page budget; more results can be read by raising it.`;
182
+ case 'exhausted':
183
+ return `${readKind} read for '${id}' was truncated and cannot be continued; results may be incomplete.`;
184
+ }
185
+ // No `default`: `IncompleteReadCause` is declared in this file, so `noImplicitReturns` already fails the
186
+ // build here if a cause is added without its own wording. (`collectionOmissionMessage`'s `satisfies never`
187
+ // is not the same case — that union is the SDK's, and can widen under a dependency bump.)
112
188
  }
113
189
 
114
190
  /** Warning for an account-wide issue read whose requested filters the provider can't express server-side. */