@gitkraken/core-gitlens 0.5.103 → 0.5.104

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 (94) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/dist/git/models/issue.d.ts +78 -0
  3. package/dist/git/models/issue.d.ts.map +1 -1
  4. package/dist/git/models/issue.js.map +1 -1
  5. package/dist/plus/git-github/api/config.d.ts +12 -0
  6. package/dist/plus/git-github/api/config.d.ts.map +1 -1
  7. package/dist/plus/git-github/api/config.js +12 -1
  8. package/dist/plus/git-github/api/config.js.map +1 -1
  9. package/dist/plus/git-github/api/github.d.ts +77 -7
  10. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  11. package/dist/plus/git-github/api/github.js +241 -120
  12. package/dist/plus/git-github/api/github.js.map +1 -1
  13. package/dist/plus/git-github/api/issueSearchQuery.d.ts +70 -0
  14. package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -0
  15. package/dist/plus/git-github/api/issueSearchQuery.js +143 -0
  16. package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -0
  17. package/dist/plus/integrations/index.d.ts +2 -0
  18. package/dist/plus/integrations/index.d.ts.map +1 -1
  19. package/dist/plus/integrations/integrationService.d.ts +39 -2
  20. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  21. package/dist/plus/integrations/integrationService.js +17 -0
  22. package/dist/plus/integrations/integrationService.js.map +1 -1
  23. package/dist/plus/integrations/manager.d.ts +88 -2
  24. package/dist/plus/integrations/manager.d.ts.map +1 -1
  25. package/dist/plus/integrations/models/gitHostIntegration.d.ts +61 -3
  26. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  27. package/dist/plus/integrations/models/gitHostIntegration.js +51 -0
  28. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  29. package/dist/plus/integrations/models/integration.d.ts +3 -46
  30. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  31. package/dist/plus/integrations/models/integration.js.map +1 -1
  32. package/dist/plus/integrations/models/issueReads.d.ts +70 -0
  33. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -0
  34. package/dist/plus/integrations/models/issueReads.js +2 -0
  35. package/dist/plus/integrations/models/issueReads.js.map +1 -0
  36. package/dist/plus/integrations/providerFilters.d.ts +10 -0
  37. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  38. package/dist/plus/integrations/providers/github.d.ts +26 -3
  39. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  40. package/dist/plus/integrations/providers/github.js +34 -0
  41. package/dist/plus/integrations/providers/github.js.map +1 -1
  42. package/dist/plus/integrations/providers/models.d.ts +22 -0
  43. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  44. package/dist/plus/integrations/providers/models.js +26 -0
  45. package/dist/plus/integrations/providers/models.js.map +1 -1
  46. package/dist/plus/integrations/reads/broaden.d.ts +10 -0
  47. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  48. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  49. package/dist/plus/integrations/reads/counts.d.ts +64 -0
  50. package/dist/plus/integrations/reads/counts.d.ts.map +1 -0
  51. package/dist/plus/integrations/reads/counts.js +164 -0
  52. package/dist/plus/integrations/reads/counts.js.map +1 -0
  53. package/dist/plus/integrations/reads/filters.d.ts +63 -1
  54. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  55. package/dist/plus/integrations/reads/filters.js +125 -0
  56. package/dist/plus/integrations/reads/filters.js.map +1 -1
  57. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  58. package/dist/plus/integrations/reads/issues.js +22 -41
  59. package/dist/plus/integrations/reads/issues.js.map +1 -1
  60. package/dist/plus/integrations/reads/paging.d.ts +59 -0
  61. package/dist/plus/integrations/reads/paging.d.ts.map +1 -1
  62. package/dist/plus/integrations/reads/paging.js +60 -0
  63. package/dist/plus/integrations/reads/paging.js.map +1 -1
  64. package/dist/plus/integrations/reads/searchIssues.d.ts +55 -0
  65. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -0
  66. package/dist/plus/integrations/reads/searchIssues.js +144 -0
  67. package/dist/plus/integrations/reads/searchIssues.js.map +1 -0
  68. package/dist/plus/integrations/reads/warnings.d.ts +25 -1
  69. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  70. package/dist/plus/integrations/reads/warnings.js +86 -2
  71. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  72. package/docs/integrations.md +110 -2
  73. package/docs/kepler-read-api-parity.md +57 -0
  74. package/package.json +1 -1
  75. package/src/git/models/issue.ts +81 -0
  76. package/src/plus/git-github/api/config.ts +13 -0
  77. package/src/plus/git-github/api/github.ts +332 -148
  78. package/src/plus/git-github/api/issueSearchQuery.ts +165 -0
  79. package/src/plus/integrations/index.ts +5 -0
  80. package/src/plus/integrations/integrationService.ts +47 -1
  81. package/src/plus/integrations/manager.ts +88 -2
  82. package/src/plus/integrations/models/gitHostIntegration.ts +102 -2
  83. package/src/plus/integrations/models/integration.ts +7 -46
  84. package/src/plus/integrations/models/issueReads.ts +73 -0
  85. package/src/plus/integrations/providerFilters.ts +15 -0
  86. package/src/plus/integrations/providers/github.ts +64 -2
  87. package/src/plus/integrations/providers/models.ts +49 -0
  88. package/src/plus/integrations/reads/broaden.ts +10 -0
  89. package/src/plus/integrations/reads/counts.ts +285 -0
  90. package/src/plus/integrations/reads/filters.ts +162 -1
  91. package/src/plus/integrations/reads/issues.ts +22 -45
  92. package/src/plus/integrations/reads/paging.ts +102 -0
  93. package/src/plus/integrations/reads/searchIssues.ts +252 -0
  94. package/src/plus/integrations/reads/warnings.ts +114 -4
@@ -1,5 +1,12 @@
1
1
  import type { IntegrationIds } from '../constants.js';
2
- import type { IssueFilter, PullRequestFilter } from '../providerFilters.js';
2
+ import type {
3
+ IssueFilter,
4
+ IssueSearchCapabilities,
5
+ IssueSearchCriteria,
6
+ IssueSearchRelationship,
7
+ PullRequestFilter,
8
+ } from '../providerFilters.js';
9
+ import type { ProviderRepoInput, ProviderReposInput } from '../providers/models.js';
3
10
  import { providersMetadata } from '../providers/models.js';
4
11
 
5
12
  /**
@@ -79,6 +86,139 @@ export function resolveAccountWideIssueFilters(
79
86
  return { filters: filters, unsupported: false };
80
87
  }
81
88
 
89
+ /** Why {@link resolveIssueSearchCriteria} refused a criteria set, so the caller can word the refusal exactly. */
90
+ export type IssueSearchCriteriaRejection =
91
+ /** The provider has no filtered issue search at all. */
92
+ | { reason: 'unsupported-search' }
93
+ /** The provider has one, but can't express these criteria server-side. */
94
+ | { reason: 'unsupported-criteria'; criteria: string[] }
95
+ /** `any-assignee` and `unassigned` partition the scope between them; asking for both asks for nothing. */
96
+ | { reason: 'contradictory-relationships' };
97
+
98
+ /**
99
+ * Validates a filtered issue search's criteria against {@link ProviderMetadata.supportedIssueSearch}.
100
+ *
101
+ * All-or-nothing like its three siblings above, and for the same reason: a criterion dropped because the
102
+ * provider can't express it would serve a WIDER result than was asked for, and narrowing the returned page
103
+ * afterward would leave `items` describing a different result set than the `hasMore`/`cursor` the provider
104
+ * produced with it. So an inexpressible set is refused whole and the caller surfaces a warning.
105
+ *
106
+ * Unlike the sibling validators this also rejects a set that is internally contradictory, because this criteria
107
+ * model is the only one with two members that partition the scope between them (`any-assignee` ∪ `unassigned` =
108
+ * everything, ∩ = nothing). Silently keeping one would answer a question the caller didn't ask.
109
+ *
110
+ * `undefined` (or an empty relationship list plus no other criterion) is a valid UNNARROWED search of the given
111
+ * scope, not an error — the caller's `repos`/`org` is what bounds it.
112
+ */
113
+ export function resolveIssueSearchCriteria(
114
+ id: IntegrationIds,
115
+ criteria: IssueSearchCriteria | undefined,
116
+ ): { rejection?: IssueSearchCriteriaRejection } {
117
+ const supported = providersMetadata[id]?.supportedIssueSearch;
118
+ if (supported == null) return { rejection: { reason: 'unsupported-search' } };
119
+ if (criteria == null) return {};
120
+
121
+ const relationships = criteria.relationships;
122
+ if (relationships?.includes('any-assignee') && relationships.includes('unassigned')) {
123
+ return { rejection: { reason: 'contradictory-relationships' } };
124
+ }
125
+
126
+ const unsupported: string[] = [];
127
+ for (const relationship of relationships ?? []) {
128
+ if (!supported.relationships.includes(relationship)) {
129
+ unsupported.push(`relationships:${relationship}`);
130
+ }
131
+ }
132
+ // Only a criterion the caller actually SET can be unsupported. `false`/empty means "don't narrow on this",
133
+ // which every provider can honor by doing nothing, so it must not be validated as a request.
134
+ if (criteria.text != null && criteria.text.trim().length > 0 && !supported.text) {
135
+ unsupported.push('text');
136
+ }
137
+ if (criteria.labels?.length && !supported.labels) {
138
+ unsupported.push('labels');
139
+ }
140
+ if (criteria.milestone != null && !supported.milestone) {
141
+ unsupported.push('milestone');
142
+ }
143
+ if (criteria.updatedAfter != null && !supported.updatedAfter) {
144
+ unsupported.push('updatedAfter');
145
+ }
146
+ if (criteria.createdAfter != null && !supported.createdAfter) {
147
+ unsupported.push('createdAfter');
148
+ }
149
+ if (criteria.withoutLinkedPullRequest === true && !supported.withoutLinkedPullRequest) {
150
+ unsupported.push('withoutLinkedPullRequest');
151
+ }
152
+ // `'open'` is every provider's own default, so asking for it needs no state capability.
153
+ if (criteria.state != null && criteria.state !== 'open' && !supported.states) {
154
+ unsupported.push('state');
155
+ }
156
+
157
+ if (unsupported.length > 0) return { rejection: { reason: 'unsupported-criteria', criteria: unsupported } };
158
+
159
+ return {};
160
+ }
161
+
162
+ /**
163
+ * The relationships that bound a filtered issue search to the CURRENT USER, and so can stand in for a
164
+ * repository/org scope.
165
+ *
166
+ * Derived from the union rather than spelled out per call site, because the distinction it encodes is the whole
167
+ * reason the scope rule exists: `any-assignee` and `unassigned` are deliberately absent — they describe the
168
+ * ISSUE, not the caller, so neither reduces the search to anyone's own world (measured: unscoped `no:assignee`
169
+ * matches ~45 million issues on GitHub).
170
+ */
171
+ const userScopingIssueSearchRelationships: readonly IssueSearchRelationship[] = ['authored', 'assigned', 'mentioned'];
172
+
173
+ /** What a provider with NO filtered issue search reports: nothing expressible, spelled out rather than absent. */
174
+ const unsupportedIssueSearchCapabilities: IssueSearchCapabilities = {
175
+ relationships: [],
176
+ text: false,
177
+ labels: false,
178
+ milestone: false,
179
+ updatedAfter: false,
180
+ createdAfter: false,
181
+ withoutLinkedPullRequest: false,
182
+ states: false,
183
+ };
184
+
185
+ /** Why a filtered issue search's scope was refused, or `undefined` when it is usable. */
186
+ export type IssueSearchScopeRejection =
187
+ /** No repositories, no org, and no user-relative relationship: a search of the whole host. */
188
+ | 'unscoped'
189
+ /** Repositories given as ids. A search names repositories by PATH, so ids can't express a scope. */
190
+ | 'repo-ids';
191
+
192
+ /**
193
+ * Validates that a filtered issue search is scoped at all, and narrows `repos` to the descriptor form the
194
+ * provider query needs.
195
+ *
196
+ * Shared by `searchIssuesPage` and `countIssues` because a count computed under different constraints than the
197
+ * read it previews is a WRONG number rather than a missing one. The rejection is returned as a reason code, not a
198
+ * warning: the two callers word it differently (whole-read vs naming the offending scope's key), and wording is
199
+ * the warning layer's business.
200
+ *
201
+ * The two rejections are mutually exclusive — `repo-ids` requires repositories and `unscoped` requires none — so
202
+ * the order they're checked in cannot change the outcome.
203
+ */
204
+ export function resolveIssueSearchScope(
205
+ repos: ProviderReposInput | undefined,
206
+ org: string | undefined,
207
+ criteria: IssueSearchCriteria | undefined,
208
+ ): { rejection?: IssueSearchScopeRejection; repos?: ProviderRepoInput[] } {
209
+ if (repos?.length) {
210
+ // `ProviderReposInput` is a union of descriptor and id arrays; only the descriptor form is usable here.
211
+ if (repos.some(r => typeof r === 'string' || typeof r === 'number')) return { rejection: 'repo-ids' };
212
+
213
+ return { repos: repos as ProviderRepoInput[] };
214
+ }
215
+
216
+ if (org != null && org.length > 0) return {};
217
+ if (criteria?.relationships?.some(r => userScopingIssueSearchRelationships.includes(r)) === true) return {};
218
+
219
+ return { rejection: 'unscoped' };
220
+ }
221
+
82
222
  /**
83
223
  * The filters `listPullRequestsPage`/`listIssuesPage` (and the sweeps) accept for a provider, so a caller can
84
224
  * narrow to what the provider can express BEFORE issuing the read.
@@ -103,6 +243,11 @@ export function resolveAccountWideIssueFilters(
103
243
  * {@link IntegrationService.listIssueTrackerIssuesPage} validates against, and leaves `issuesAccountWide`
104
244
  * empty. Reading a tracker's capability off `issuesAccountWide` therefore under-reports it.
105
245
  *
246
+ * `issueSearch` is a third, wider surface: the FILTERED issue search (`searchIssuesPage`, and the `countIssues`
247
+ * probe over the same criteria), which is not bound to the user at all. It is a record of per-criterion flags
248
+ * rather than a list, because its criteria aren't a single kind. An empty `relationships` means the provider has
249
+ * no filtered issue search — hide the surface, not just its chips.
250
+ *
106
251
  * Note this is a CAPABILITY table — "what the provider can express" — not a recommendation. A consumer
107
252
  * matching another tool's behavior may deliberately pass fewer filters than are listed here (or none, where an
108
253
  * already-scoped read would only be narrowed by them). Intersecting against this table is what keeps a
@@ -113,12 +258,28 @@ export function getSupportedFilters(providerId: IntegrationIds): {
113
258
  pullRequestsAccountWide: PullRequestFilter[];
114
259
  issues: IssueFilter[];
115
260
  issuesAccountWide: IssueFilter[];
261
+ issueSearch: IssueSearchCapabilities;
116
262
  } {
117
263
  const metadata = providersMetadata[providerId];
264
+ const issueSearch = metadata?.supportedIssueSearch;
118
265
  return {
119
266
  pullRequests: [...(metadata?.supportedPullRequestFilters ?? [])],
120
267
  pullRequestsAccountWide: [...(metadata?.supportedAccountWidePullRequestFilters ?? [])],
121
268
  issues: [...(metadata?.supportedIssueFilters ?? [])],
122
269
  issuesAccountWide: [...(metadata?.supportedAccountWideIssueFilters ?? [])],
270
+ // Always an object, never `undefined`: a provider WITHOUT a filtered issue search reports one whose
271
+ // `relationships` is empty and whose flags are all false, so a consumer reads capabilities the same way
272
+ // for every provider (and an empty `relationships` is the signal to hide the surface itself).
273
+ //
274
+ // Spread over the all-false baseline rather than defaulting each flag: a criterion added to
275
+ // `IssueSearchCapabilities` then can't be forgotten here and silently reported as `undefined` (which a
276
+ // consumer's `if (caps.x)` would read as unsupported — right answer, wrong reason, and untyped).
277
+ // `supportedIssueSearch` is typed as the complete shape, so no member can arrive as an explicit undefined.
278
+ issueSearch: {
279
+ ...unsupportedIssueSearchCapabilities,
280
+ ...issueSearch,
281
+ // Copied, so mutating the result can't corrupt the metadata table.
282
+ relationships: [...(issueSearch?.relationships ?? [])],
283
+ },
123
284
  };
124
285
  }
@@ -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, reconcileOmissionsWithFailure } from '../results.js';
9
+ import { reconcileOmissionsWithFailure } from '../results.js';
10
10
  import {
11
11
  isGitHostIntegration,
12
12
  isIssuesHostIntegrationId,
@@ -16,6 +16,7 @@ import type { ProviderReadContext } from './context.js';
16
16
  import { runCaptured } from './drains.js';
17
17
  import { resolveAccountWideIssueFilters } from './filters.js';
18
18
  import {
19
+ drainFlatPagesToRequestedPage,
19
20
  drainToRequestedPage,
20
21
  isPageNumberAdvanceable,
21
22
  pageToCursor,
@@ -121,6 +122,11 @@ export async function listIssuesPage(
121
122
  );
122
123
  }
123
124
 
125
+ // GitHub expresses "any assignee" as `assignee:*`, which is meaningless without a scope: unscoped it
126
+ // matches millions of issues across all of GitHub instead of the user's own world. ANY scope makes it
127
+ // meaningful (one repository, several, or an org) — it is specifically THIS branch, the account-wide read,
128
+ // that has none to offer. Scope the read to repositories (or use `searchIssuesPage`, whose criteria model
129
+ // takes `any-assignee` alongside a `repos`/`org` scope) rather than reading unscoped.
124
130
  if (
125
131
  options.includeAllAssignees === true &&
126
132
  (options.providerId === GitCloudHostIntegrationId.GitHub ||
@@ -185,51 +191,22 @@ export async function listIssuesPage(
185
191
  { warnOnMissingSession: warnOnMissingSession },
186
192
  );
187
193
  const first = await readAccountWidePage(options.cursor);
188
- let value = first.value;
189
194
  const warnings = first.warning != null ? [first.warning] : [];
190
- let allMetadata = value?.metadata;
191
- let pageFetchFailed = first.warning != null && value == null;
192
- let currentPage = value?.page ?? 1;
193
- let currentTruncated = value?.truncated ?? false;
194
- let requestedPageMissing = false;
195
- if (options.cursor == null && page > 1 && value != null) {
196
- // Guard against the empty-cursor sentinel: a provider that claims another page without handing
197
- // back a usable cursor would otherwise be re-read with `'{}'` and answer with page 1 again.
198
- for (
199
- let nextCursor = usableCursor(value.cursor);
200
- currentPage < page && value.hasMore && nextCursor != null;
201
- nextCursor = usableCursor(value.cursor)
202
- ) {
203
- const next = await readAccountWidePage(nextCursor);
204
- if (next.warning != null) {
205
- appendDedupedWarning(warnings, next.warning);
206
- }
207
- if (next.value == null) {
208
- pageFetchFailed = pageFetchFailed || next.warning != null;
209
- value = undefined;
210
- requestedPageMissing = true;
211
- break;
212
- }
213
-
214
- value = next.value;
215
- allMetadata = mergeCollectionMetadata(allMetadata, value.metadata);
216
- currentTruncated = currentTruncated || value.truncated;
217
- currentPage = value.page ?? currentPage + 1;
218
- // A provider that hands back the same cursor isn't advancing; stop rather than loop forever.
219
- if (usableCursor(value.cursor) === nextCursor) {
220
- currentTruncated = true;
221
- break;
222
- }
223
- }
224
-
225
- // A numbered page beyond the provider's terminal cursor is genuinely empty. Never return or
226
- // relabel the last available page as the requested one.
227
- if (currentPage < page) {
228
- requestedPageMissing = true;
229
- }
230
- } else if (options.cursor == null && page > 1) {
231
- requestedPageMissing = true;
232
- }
195
+ // Merged across the walked pages, so a per-scope failure reported on an earlier page isn't lost by
196
+ // paging past it the one thing this read accumulates that the filtered search doesn't.
197
+ let allMetadata = first.value?.metadata;
198
+ const drained = await drainFlatPagesToRequestedPage(first, {
199
+ requestedPage: page,
200
+ suppliedCursor: options.cursor,
201
+ warnings: warnings,
202
+ readPage: readAccountWidePage,
203
+ fold: p => {
204
+ allMetadata = mergeCollectionMetadata(allMetadata, p.metadata);
205
+ },
206
+ });
207
+ const { value, currentPage, requestedPageMissing } = drained;
208
+ const currentTruncated = drained.truncated;
209
+ const pageFetchFailed = drained.fetchFailed;
233
210
 
234
211
  // GitHub, GitLab, and Azure implement an account-wide issue search; a provider that doesn't (Bitbucket
235
212
  // exposes no issues at all, and `supportsIssues` already short-circuits it above) returns `undefined`
@@ -180,6 +180,108 @@ export function resolveCurrentPage(options: {
180
180
  return options.pageAdvanceable ? options.requestedPage : 1;
181
181
  }
182
182
 
183
+ /**
184
+ * One page of a FLAT cursor-paged provider read — the shape both issue reads' providers return directly.
185
+ *
186
+ * `hasMore` is optional because one of the two providers leaves it off on a terminal page; absent reads as "no
187
+ * more", which is what a provider that reported no continuation means.
188
+ */
189
+ export interface FlatPage {
190
+ cursor?: string;
191
+ hasMore?: boolean;
192
+ page?: number;
193
+ truncated: boolean;
194
+ }
195
+
196
+ /** What {@link drainFlatPagesToRequestedPage} established by walking. */
197
+ export interface FlatDrainResult<T extends FlatPage> {
198
+ /** The requested page, or `undefined` when a page failed mid-walk (which also sets `fetchFailed`). */
199
+ value: T | undefined;
200
+ /** The position actually reached — 1 when nothing advanced. */
201
+ currentPage: number;
202
+ truncated: boolean;
203
+ /** True when the requested page is past the provider's last one, so it is genuinely EMPTY. */
204
+ requestedPageMissing: boolean;
205
+ fetchFailed: boolean;
206
+ }
207
+
208
+ /**
209
+ * Walks a FLAT cursor-paged read forward to a requested page number, for a provider whose pages are
210
+ * `{cursor, hasMore, page, truncated}` rather than the SDK's `PagedResult` wrapper.
211
+ *
212
+ * The sibling of {@link drainToRequestedPage}, not a replacement for it: that one normalizes SDK `paging` through
213
+ * {@link toProviderPageInfo}, which retains only cursors whose JSON declares `type`/`cursors` — and the composite
214
+ * cursor of a GitHub aliased search declares neither, so it would be dropped and the walk would never advance.
215
+ * Two shapes genuinely need two entry points; what they must NOT have is two copies of the rules below, which is
216
+ * what this exists to prevent (see this module's own note on drift).
217
+ *
218
+ * The rules, all of them subtle enough to be worth stating once:
219
+ * - The empty-cursor sentinel is filtered, so a provider claiming another page without handing back a usable
220
+ * continuation is not re-read with `'{}'` (which it would answer with page 1 again).
221
+ * - A provider that returns the SAME cursor isn't advancing: stop, and report the read as truncated rather than
222
+ * looping forever or publishing a short page as complete.
223
+ * - A page requested past the provider's terminal cursor is that EMPTY page N — never the last available page
224
+ * relabeled, per {@link ProviderPageInfo.currentPage}.
225
+ * - A page that fails mid-walk latches `fetchFailed` and leaves no value, so the caller can tell "the read broke"
226
+ * from "the page was genuinely empty".
227
+ *
228
+ * `fold` is the one thing the callers differ on — one merges SDK collection metadata across the walked pages, the
229
+ * other keeps the largest reported match count — so it is a callback rather than a branch.
230
+ */
231
+ export async function drainFlatPagesToRequestedPage<T extends FlatPage>(
232
+ first: { value?: T; warning?: ProviderWarning },
233
+ options: {
234
+ requestedPage: number;
235
+ suppliedCursor: string | undefined;
236
+ warnings: ProviderWarning[];
237
+ readPage: (cursor: string) => Promise<{ value?: T; warning?: ProviderWarning }>;
238
+ fold?: (page: T) => void;
239
+ },
240
+ ): Promise<FlatDrainResult<T>> {
241
+ let value = first.value;
242
+ let fetchFailed = first.warning != null && value == null;
243
+ let currentPage = value?.page ?? 1;
244
+ let truncated = value?.truncated ?? false;
245
+
246
+ const walkable = options.suppliedCursor == null && options.requestedPage > 1;
247
+ if (walkable && value != null) {
248
+ for (
249
+ let nextCursor = usableCursor(value.cursor);
250
+ currentPage < options.requestedPage && value.hasMore === true && nextCursor != null;
251
+ nextCursor = usableCursor(value.cursor)
252
+ ) {
253
+ const next = await options.readPage(nextCursor);
254
+ if (next.warning != null) {
255
+ appendDedupedWarning(options.warnings, next.warning);
256
+ }
257
+ if (next.value == null) {
258
+ fetchFailed = next.warning != null;
259
+ value = undefined;
260
+ break;
261
+ }
262
+
263
+ value = next.value;
264
+ truncated = truncated || value.truncated;
265
+ options.fold?.(value);
266
+ currentPage = value.page ?? currentPage + 1;
267
+ if (usableCursor(value.cursor) === nextCursor) {
268
+ truncated = true;
269
+ break;
270
+ }
271
+ }
272
+ }
273
+
274
+ return {
275
+ value: value,
276
+ currentPage: currentPage,
277
+ truncated: truncated,
278
+ // Every case reduces to "a page was asked for by number and the walk fell short", including the one where
279
+ // no walk ran at all because the first page was already terminal.
280
+ requestedPageMissing: walkable && currentPage < options.requestedPage,
281
+ fetchFailed: fetchFailed,
282
+ };
283
+ }
284
+
183
285
  /** The mutable state {@link drainToRequestedPage} carries across the pages it walks. */
184
286
  export interface DrainState<T> {
185
287
  items: T[];
@@ -0,0 +1,252 @@
1
+ import type { IssueSearchCriteria, IssueShape } from '../../../git/models/issue.js';
2
+ import type { IntegrationIds } from '../constants.js';
3
+ import type { ProviderReposInput } from '../providers/models.js';
4
+ import type { ProviderPagedResult, ProviderWarning } from '../results.js';
5
+ import {
6
+ isGitHostIntegration,
7
+ isIssuesHostIntegrationId,
8
+ warnOnMissingSessionForDomain,
9
+ } from '../utils/integration.utils.js';
10
+ import type { ProviderReadContext } from './context.js';
11
+ import { runCaptured } from './drains.js';
12
+ import { resolveIssueSearchCriteria, resolveIssueSearchScope } from './filters.js';
13
+ import {
14
+ drainFlatPagesToRequestedPage,
15
+ refusedPage,
16
+ resolveContinuation,
17
+ resolveCurrentPage,
18
+ usableCursor,
19
+ } from './paging.js';
20
+ import {
21
+ gitHostOnlySurfaceWarning,
22
+ issueSearchCapResultWarning,
23
+ issuesUnsupportedWarning,
24
+ otherWarning,
25
+ truncationWarning,
26
+ unsupportedIssueSearchCriteriaWarning,
27
+ } from './warnings.js';
28
+
29
+ /**
30
+ * The FILTERED issue search: issues matching structured criteria over a repository/org scope, with no forced
31
+ * relationship to the current user.
32
+ *
33
+ * A sibling of `listIssuesPage` rather than a mode of it, deliberately. That read is already two divergent
34
+ * branches (a cursor-composite account-wide path and a page-draining repo-scoped one) around a documented
35
+ * contract where `filters` REPLACES the provider's definition of "my issues". This search answers a different
36
+ * question — its relationships include two that are not about the user at all — and routes around the SDK's
37
+ * repo-scoped read entirely, whose over-limit recovery walk can spend up to 128 requests and still return an
38
+ * incomplete set. Folding the two together would multiply that branch matrix and put every existing consumer of
39
+ * `listIssuesPage` at risk; keeping them siblings shares the primitives below without sharing the branch space,
40
+ * for the same reason `broaden.ts`, `sweeps.ts` and `issueTracker.ts` are separate files.
41
+ *
42
+ * Contract details worth knowing before calling:
43
+ * - Ordering is ALWAYS most-recently-updated-first. Not an option: a "show the N most recent" policy at the
44
+ * provider's result ceiling is only correct under a guaranteed order.
45
+ * - Paging is cursor-only. `page` alone walks 1..N internally (O(N) requests); a page past the last one is an
46
+ * empty page N, never page N−1 relabeled.
47
+ * - At the provider's result ceiling the read SUCCEEDS and reports an omission carrying the total match count,
48
+ * so a consumer can say how many were withheld. It never falls back to a per-repository recovery walk.
49
+ */
50
+ export async function searchIssuesPage(
51
+ ctx: ProviderReadContext,
52
+ options: {
53
+ providerId: IntegrationIds;
54
+ /** Repositories to search. Combines with `org`; both become scope constraints on the same query. */
55
+ repos?: ProviderReposInput;
56
+ /** Organization/account to search. Combines with `repos`. */
57
+ org?: string;
58
+ /**
59
+ * What to narrow on, validated against `getSupportedFilters().issueSearch` before the read runs. A
60
+ * criterion the provider can't express server-side refuses the whole read (warning + `fetchFailed`)
61
+ * rather than serving a list that was never narrowed.
62
+ */
63
+ criteria?: IssueSearchCriteria;
64
+ page?: number;
65
+ cursor?: string;
66
+ /**
67
+ * Page size PER RELATIONSHIP: each one is its own provider query, so a page of an N-relationship search
68
+ * returns up to `N × itemsPerPage` items before the url dedupe, and fewer where they overlap.
69
+ * `page.itemsPerPage` reports what actually came back.
70
+ */
71
+ itemsPerPage?: number;
72
+ forceSync?: boolean;
73
+ connectionId?: string;
74
+ /**
75
+ * Explicit self-managed host domain. Used only when the requested connection has no configured domain;
76
+ * it must come from the trusted authentication configuration, not repository or remote data.
77
+ */
78
+ domain?: string;
79
+ },
80
+ ): Promise<ProviderPagedResult<IssueShape>> {
81
+ const page = Math.max(1, Math.trunc(options.page ?? 1));
82
+ // Cursor-only, so a refusal can't claim the requested position — it reports page 1, per
83
+ // ProviderPageInfo.currentPage.
84
+ const refused = (warning: ProviderWarning) => refusedPage<IssueShape>(1, [warning], true);
85
+
86
+ if (isIssuesHostIntegrationId(options.providerId)) {
87
+ return refused(
88
+ gitHostOnlySurfaceWarning(options.providerId, undefined, options.connectionId, 'Filtered issue search'),
89
+ );
90
+ }
91
+
92
+ const integration = await ctx.getIntegrationForRead(options.providerId, options.connectionId, options.domain);
93
+ if (integration == null) {
94
+ // A supplied connection or domain that no longer resolves is a broken target, not an empty account —
95
+ // surface a no-connection warning + fetchFailed rather than a silent empty page.
96
+ const early = ctx.earlyReturnConnectionWarnings(options.providerId, options.connectionId, options.domain);
97
+ return refusedPage(1, early.warnings, early.fetchFailed);
98
+ }
99
+ if (!isGitHostIntegration(integration)) {
100
+ return refused(
101
+ gitHostOnlySurfaceWarning(options.providerId, undefined, options.connectionId, 'Filtered issue search'),
102
+ );
103
+ }
104
+
105
+ await ctx.forceRefreshIfRequested(integration, options.forceSync, options.connectionId);
106
+
107
+ const domain = ctx.domainForRead(integration, options.providerId, options.connectionId, options.domain);
108
+ const warnOnMissingSession = warnOnMissingSessionForDomain(options.providerId, options.domain);
109
+
110
+ if (!integration.supportsIssues) {
111
+ return refused(issuesUnsupportedWarning(options.providerId, domain, options.connectionId));
112
+ }
113
+
114
+ const scope = resolveIssueSearchScope(options.repos, options.org, options.criteria);
115
+ switch (scope.rejection) {
116
+ case 'unscoped':
117
+ return refused(
118
+ otherWarning(
119
+ options.providerId,
120
+ domain,
121
+ options.connectionId,
122
+ 'A filtered issue search must be scoped: pass `repos`, `org`, or a relationship to the current user (`authored`/`assigned`/`mentioned`). `any-assignee` and `unassigned` are not scopes — either one alone matches every such issue on the host.',
123
+ ),
124
+ );
125
+ case 'repo-ids':
126
+ // A search query names repositories by PATH, so repository ids can't be expressed as a scope. Refusing
127
+ // is the only honest answer: dropping them would search the whole org (or the whole host) as if the
128
+ // caller had asked for that.
129
+ return refused(
130
+ otherWarning(
131
+ options.providerId,
132
+ domain,
133
+ options.connectionId,
134
+ 'A filtered issue search cannot be scoped by repository id; pass repository descriptors (namespace + name) instead.',
135
+ ),
136
+ );
137
+ }
138
+
139
+ const resolved = resolveIssueSearchCriteria(options.providerId, options.criteria);
140
+ if (resolved.rejection != null) {
141
+ return refused(
142
+ unsupportedIssueSearchCriteriaWarning(options.providerId, domain, options.connectionId, resolved.rejection),
143
+ );
144
+ }
145
+
146
+ const readPage = (cursor: string | undefined) =>
147
+ runCaptured(
148
+ options.providerId,
149
+ domain,
150
+ options.connectionId,
151
+ () =>
152
+ integration.searchIssuesPageResult(
153
+ {
154
+ repos: scope.repos,
155
+ org: options.org,
156
+ criteria: options.criteria,
157
+ cursor: cursor,
158
+ pageSize: options.itemsPerPage,
159
+ },
160
+ undefined,
161
+ options.connectionId,
162
+ ),
163
+ { warnOnMissingSession: warnOnMissingSession },
164
+ );
165
+
166
+ const first = await readPage(options.cursor);
167
+ const warnings = first.warning != null ? [first.warning] : [];
168
+ // The largest total any page reported. Folded across the walk so a cap detected on page 1 is still reportable
169
+ // after paging on to page N — the one thing this read accumulates that its sibling doesn't.
170
+ let totalCount = first.value?.totalCount;
171
+ const drained = await drainFlatPagesToRequestedPage(first, {
172
+ requestedPage: page,
173
+ suppliedCursor: options.cursor,
174
+ warnings: warnings,
175
+ readPage: readPage,
176
+ fold: p => {
177
+ if (p.totalCount != null) {
178
+ totalCount = Math.max(totalCount ?? 0, p.totalCount);
179
+ }
180
+ },
181
+ });
182
+ const { value, currentPage, requestedPageMissing } = drained;
183
+ const pageFetchFailed = drained.fetchFailed;
184
+
185
+ // A provider that doesn't implement the search hook returns `undefined` with no error. Surface that as an
186
+ // explicit unsupported warning + fetchFailed rather than a silent empty success, so a caller isn't left
187
+ // unable to tell it apart from an account with no matching issues. `getSupportedFilters().issueSearch`
188
+ // declares this ahead of the read, so a consumer that checks it never reaches here.
189
+ if (value == null && warnings.length === 0) {
190
+ return refused(
191
+ unsupportedIssueSearchCriteriaWarning(options.providerId, domain, options.connectionId, {
192
+ reason: 'unsupported-search',
193
+ }),
194
+ );
195
+ }
196
+
197
+ const items = requestedPageMissing ? [] : (value?.values ?? []);
198
+ // Cursor-only: `hasMore` without a real cursor is a dead end, so report it as terminal-but-incomplete rather
199
+ // than inviting the caller to page forever.
200
+ const continuation = resolveContinuation(
201
+ {
202
+ hasMore: requestedPageMissing ? false : (value?.hasMore ?? false),
203
+ cursor: requestedPageMissing ? undefined : usableCursor(value?.cursor),
204
+ truncated: drained.truncated,
205
+ },
206
+ undefined,
207
+ );
208
+ const truncated = continuation.truncated;
209
+ // Only reachable when nothing else already explained the incompleteness — and a failed page always warns
210
+ // first, so a read that gets here SUCCEEDED. That is why the cause below is unconditionally `exhausted` and
211
+ // `fetchFailed` is never set from here.
212
+ if (truncated && warnings.length === 0) {
213
+ // The result ceiling is the one truncation cause this read can EXPLAIN with a number, so it gets its own
214
+ // warning carrying the total. Anything else truncated (an unusable continuation, a provider that stopped
215
+ // advancing) falls back to the generic wording.
216
+ warnings.push(
217
+ issueSearchCapResultWarning(options.providerId, domain, options.connectionId, totalCount) ??
218
+ truncationWarning(
219
+ options.providerId,
220
+ domain,
221
+ options.connectionId,
222
+ 'Issue search',
223
+ // `exhausted`: this read exposes no budget the caller could raise, so nothing it could call
224
+ // would return the withheld items.
225
+ 'exhausted',
226
+ ),
227
+ );
228
+ }
229
+
230
+ return {
231
+ items: items,
232
+ warnings: warnings,
233
+ page: {
234
+ // Positional, per ProviderPageInfo.currentPage. A requested page past the terminal cursor is reported
235
+ // as that empty page N; otherwise this read is cursor-only, so a `page` the caller didn't pair with a
236
+ // cursor is never echoed.
237
+ currentPage: requestedPageMissing
238
+ ? page
239
+ : resolveCurrentPage({
240
+ providerPage: currentPage,
241
+ requestedPage: page,
242
+ suppliedCursor: options.cursor,
243
+ pageAdvanceable: false,
244
+ }),
245
+ itemsPerPage: items.length,
246
+ truncated: truncated || undefined,
247
+ },
248
+ hasMore: continuation.hasMore,
249
+ cursor: continuation.cursor,
250
+ fetchFailed: pageFetchFailed || undefined,
251
+ };
252
+ }