@gitkraken/core-gitlens 0.5.103 → 0.5.105

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 (99) hide show
  1. package/CHANGELOG.md +26 -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/authentication/configuredIntegrationService.d.ts +1 -0
  18. package/dist/plus/integrations/authentication/configuredIntegrationService.d.ts.map +1 -1
  19. package/dist/plus/integrations/authentication/configuredIntegrationService.js +4 -1
  20. package/dist/plus/integrations/authentication/configuredIntegrationService.js.map +1 -1
  21. package/dist/plus/integrations/index.d.ts +2 -0
  22. package/dist/plus/integrations/index.d.ts.map +1 -1
  23. package/dist/plus/integrations/integrationService.d.ts +39 -2
  24. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  25. package/dist/plus/integrations/integrationService.js +46 -23
  26. package/dist/plus/integrations/integrationService.js.map +1 -1
  27. package/dist/plus/integrations/manager.d.ts +88 -2
  28. package/dist/plus/integrations/manager.d.ts.map +1 -1
  29. package/dist/plus/integrations/models/gitHostIntegration.d.ts +61 -3
  30. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  31. package/dist/plus/integrations/models/gitHostIntegration.js +51 -0
  32. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  33. package/dist/plus/integrations/models/integration.d.ts +3 -46
  34. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  35. package/dist/plus/integrations/models/integration.js.map +1 -1
  36. package/dist/plus/integrations/models/issueReads.d.ts +70 -0
  37. package/dist/plus/integrations/models/issueReads.d.ts.map +1 -0
  38. package/dist/plus/integrations/models/issueReads.js +2 -0
  39. package/dist/plus/integrations/models/issueReads.js.map +1 -0
  40. package/dist/plus/integrations/providerFilters.d.ts +10 -0
  41. package/dist/plus/integrations/providerFilters.d.ts.map +1 -1
  42. package/dist/plus/integrations/providers/github.d.ts +26 -3
  43. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  44. package/dist/plus/integrations/providers/github.js +34 -0
  45. package/dist/plus/integrations/providers/github.js.map +1 -1
  46. package/dist/plus/integrations/providers/models.d.ts +22 -0
  47. package/dist/plus/integrations/providers/models.d.ts.map +1 -1
  48. package/dist/plus/integrations/providers/models.js +26 -0
  49. package/dist/plus/integrations/providers/models.js.map +1 -1
  50. package/dist/plus/integrations/reads/broaden.d.ts +10 -0
  51. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  52. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  53. package/dist/plus/integrations/reads/counts.d.ts +64 -0
  54. package/dist/plus/integrations/reads/counts.d.ts.map +1 -0
  55. package/dist/plus/integrations/reads/counts.js +164 -0
  56. package/dist/plus/integrations/reads/counts.js.map +1 -0
  57. package/dist/plus/integrations/reads/filters.d.ts +63 -1
  58. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  59. package/dist/plus/integrations/reads/filters.js +125 -0
  60. package/dist/plus/integrations/reads/filters.js.map +1 -1
  61. package/dist/plus/integrations/reads/issues.d.ts.map +1 -1
  62. package/dist/plus/integrations/reads/issues.js +22 -41
  63. package/dist/plus/integrations/reads/issues.js.map +1 -1
  64. package/dist/plus/integrations/reads/paging.d.ts +59 -0
  65. package/dist/plus/integrations/reads/paging.d.ts.map +1 -1
  66. package/dist/plus/integrations/reads/paging.js +60 -0
  67. package/dist/plus/integrations/reads/paging.js.map +1 -1
  68. package/dist/plus/integrations/reads/searchIssues.d.ts +55 -0
  69. package/dist/plus/integrations/reads/searchIssues.d.ts.map +1 -0
  70. package/dist/plus/integrations/reads/searchIssues.js +144 -0
  71. package/dist/plus/integrations/reads/searchIssues.js.map +1 -0
  72. package/dist/plus/integrations/reads/warnings.d.ts +25 -1
  73. package/dist/plus/integrations/reads/warnings.d.ts.map +1 -1
  74. package/dist/plus/integrations/reads/warnings.js +86 -2
  75. package/dist/plus/integrations/reads/warnings.js.map +1 -1
  76. package/docs/integrations.md +110 -2
  77. package/docs/kepler-read-api-parity.md +57 -0
  78. package/package.json +1 -1
  79. package/src/git/models/issue.ts +81 -0
  80. package/src/plus/git-github/api/config.ts +13 -0
  81. package/src/plus/git-github/api/github.ts +332 -148
  82. package/src/plus/git-github/api/issueSearchQuery.ts +165 -0
  83. package/src/plus/integrations/authentication/configuredIntegrationService.ts +6 -1
  84. package/src/plus/integrations/index.ts +5 -0
  85. package/src/plus/integrations/integrationService.ts +130 -70
  86. package/src/plus/integrations/manager.ts +88 -2
  87. package/src/plus/integrations/models/gitHostIntegration.ts +102 -2
  88. package/src/plus/integrations/models/integration.ts +7 -46
  89. package/src/plus/integrations/models/issueReads.ts +73 -0
  90. package/src/plus/integrations/providerFilters.ts +15 -0
  91. package/src/plus/integrations/providers/github.ts +64 -2
  92. package/src/plus/integrations/providers/models.ts +49 -0
  93. package/src/plus/integrations/reads/broaden.ts +10 -0
  94. package/src/plus/integrations/reads/counts.ts +285 -0
  95. package/src/plus/integrations/reads/filters.ts +162 -1
  96. package/src/plus/integrations/reads/issues.ts +22 -45
  97. package/src/plus/integrations/reads/paging.ts +102 -0
  98. package/src/plus/integrations/reads/searchIssues.ts +252 -0
  99. package/src/plus/integrations/reads/warnings.ts +114 -4
@@ -0,0 +1,165 @@
1
+ import type { IssueSearchCriteria, IssueSearchRelationship } from '../../../git/models/issue.js';
2
+
3
+ /**
4
+ * Translation of the provider-neutral issue-search criteria into GitHub search qualifiers, and the sanitizing
5
+ * that keeps user input from becoming one.
6
+ *
7
+ * A module of its own because it is pure string building with no client, no token and no network — the part of the
8
+ * search that is worth reading, testing and reasoning about on its own, and the part where a mistake is a
9
+ * SECURITY mistake rather than a failed request: an unsanitized value that escapes its qualifier re-scopes
10
+ * someone's search. Keeping it out of the 4000-line API client is what makes that reviewable.
11
+ */
12
+
13
+ /**
14
+ * Neutralizes everything in a user-supplied value that could break out of the qualifier it lands in: the double
15
+ * quote that would close its own `"…"`, and the control characters that cannot appear in a GitHub search query
16
+ * and exist here only as a smuggling vector.
17
+ *
18
+ * Not a general escape — GitHub search has no escape syntax — so the only safe transformation is removal. But a
19
+ * control character is REPLACED WITH A SPACE rather than deleted, because deleting it FUSES the words it separated:
20
+ * a pasted or multi-line `graph\nperformance` became the single token `graphperformance`, which matches nothing —
21
+ * turning a search that had results into a silent zero (measured: 2 results vs 0). Quotes are still deleted
22
+ * outright; they separate nothing, so a space there would only pad the value.
23
+ *
24
+ * Runs of whitespace are collapsed afterwards so the emitted qualifier stays canonical — a doubled space inside
25
+ * `label:"a b"` would not match the label `a b`.
26
+ */
27
+ export function sanitizeGitHubQualifierValue(value: string): string {
28
+ return (
29
+ value
30
+ .replace(/"/g, '')
31
+ // eslint-disable-next-line no-control-regex
32
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Sanitizes free text, which is a different problem from a qualifier value: bare words are the POINT here, so it
40
+ * can't just be quoted. What must not survive is anything that would read as a qualifier — GitHub parses any
41
+ * whitespace-delimited `key:value` token as one, so a user typing `foo org:someone-else` would otherwise re-scope
42
+ * the search to another org.
43
+ *
44
+ * Tokens containing `:` are dropped whole rather than having the colon removed: the remainder would silently
45
+ * change what the user searched for, and a dropped token at least matches nothing extra. The structured criteria
46
+ * are the qualifier channel.
47
+ */
48
+ export function sanitizeGitHubSearchText(text: string): string {
49
+ return sanitizeGitHubQualifierValue(text)
50
+ .split(/\s+/)
51
+ .filter(token => token.length > 0 && !token.includes(':'))
52
+ .join(' ');
53
+ }
54
+
55
+ /**
56
+ * How each relationship is expressed: its GitHub search qualifier (`@me` binds to the token's own user), and the
57
+ * GraphQL alias its search runs under.
58
+ *
59
+ * The aliases are LITERALS rather than derived from the relationship name, for two reasons: `any-assignee` is not
60
+ * a valid GraphQL name, and an alias is also a persisted cursor key (see {@link GitHubApi.searchIssuesByAlias}),
61
+ * so it must be stable across releases — spelling them out makes that immovability visible instead of an
62
+ * emergent property of a transform. Being a `Record` over the union, the type fails the build if a relationship
63
+ * is added without both, rather than silently emitting an unconstrained search.
64
+ */
65
+ export const gitHubIssueSearchRelationships: Record<IssueSearchRelationship, { qualifier: string; alias: string }> = {
66
+ authored: { qualifier: 'author:@me', alias: 'authored' },
67
+ assigned: { qualifier: 'assignee:@me', alias: 'assigned' },
68
+ mentioned: { qualifier: 'mentions:@me', alias: 'mentioned' },
69
+ 'any-assignee': { qualifier: 'assignee:*', alias: 'anyAssignee' },
70
+ unassigned: { qualifier: 'no:assignee', alias: 'unassigned' },
71
+ };
72
+
73
+ /**
74
+ * The scope half of an issue search query: `org:` plus one `repo:` per repository.
75
+ *
76
+ * Shared by the search and the count probe rather than written twice, because the count is only meaningful if it
77
+ * applies EXACTLY the qualifiers the search would — a count under different constraints is a wrong number, not a
78
+ * missing one. A value emptied by sanitizing is dropped rather than emitted as a bare `org:`/`repo:`, which
79
+ * GitHub rejects; that is the same rule {@link toGitHubIssueSearchQualifiers} applies to its own values.
80
+ */
81
+ export function toGitHubIssueSearchScopeQualifiers(
82
+ org: string | undefined,
83
+ repos: readonly string[] | undefined,
84
+ ): string[] {
85
+ const qualifiers: string[] = [];
86
+
87
+ if (org != null) {
88
+ const value = sanitizeGitHubQualifierValue(org);
89
+ if (value.length > 0) {
90
+ qualifiers.push(`org:${value}`);
91
+ }
92
+ }
93
+
94
+ for (const repo of repos ?? []) {
95
+ const value = sanitizeGitHubQualifierValue(repo);
96
+ if (value.length > 0) {
97
+ qualifiers.push(`repo:${value}`);
98
+ }
99
+ }
100
+
101
+ return qualifiers;
102
+ }
103
+
104
+ /**
105
+ * Translates the provider-neutral criteria into GitHub search qualifiers, EXCLUDING the relationship (which
106
+ * becomes its own aliased search, since GitHub AND-s qualifiers and relationships are OR-ed) and excluding the
107
+ * repository/org scope (which {@link toGitHubIssueSearchScopeQualifiers} owns).
108
+ *
109
+ * `sort:updated` is always emitted: the read's contract is most-recently-updated-first, so a consumer's "show the
110
+ * N most recent" policy at the result ceiling is correct. Free-form values go through the sanitizers above, and
111
+ * one emptied by sanitizing is dropped rather than emitted as an empty qualifier.
112
+ */
113
+ export function toGitHubIssueSearchQualifiers(criteria: IssueSearchCriteria | undefined): string[] {
114
+ const qualifiers = ['type:issue'];
115
+
116
+ switch (criteria?.state) {
117
+ case 'closed':
118
+ qualifiers.push('is:closed');
119
+ break;
120
+ // Every state — no qualifier constrains it.
121
+ case 'all':
122
+ break;
123
+ default:
124
+ qualifiers.push('is:open');
125
+ break;
126
+ }
127
+
128
+ if (criteria?.includeArchived !== true) {
129
+ qualifiers.push('archived:false');
130
+ }
131
+
132
+ // One place to keep the "a value emptied by sanitizing is DROPPED, never emitted as an empty qualifier" rule,
133
+ // so a criterion added later can't skip it — a bare `milestone:""` is rejected by GitHub outright.
134
+ const pushSanitized = (value: string | undefined, toQualifier: (sanitized: string) => string): void => {
135
+ if (value == null) return;
136
+
137
+ const sanitized = sanitizeGitHubQualifierValue(value);
138
+ if (sanitized.length > 0) {
139
+ qualifiers.push(toQualifier(sanitized));
140
+ }
141
+ };
142
+
143
+ for (const label of criteria?.labels ?? []) {
144
+ pushSanitized(label, v => `label:"${v}"`);
145
+ }
146
+ pushSanitized(criteria?.milestone, v => `milestone:"${v}"`);
147
+ pushSanitized(criteria?.updatedAfter, v => `updated:>=${v}`);
148
+ pushSanitized(criteria?.createdAfter, v => `created:>=${v}`);
149
+
150
+ if (criteria?.withoutLinkedPullRequest === true) {
151
+ qualifiers.push('-linked:pr');
152
+ }
153
+
154
+ if (criteria?.text != null) {
155
+ const text = sanitizeGitHubSearchText(criteria.text);
156
+ if (text.length > 0) {
157
+ qualifiers.push(text);
158
+ }
159
+ }
160
+
161
+ // Contract, not an option: see `GitHubApi.searchIssuesPage`.
162
+ qualifiers.push('sort:updated');
163
+
164
+ return qualifiers;
165
+ }
@@ -40,6 +40,7 @@ export interface ConfiguredIntegrationsChangeEvent {
40
40
 
41
41
  export class ConfiguredIntegrationService implements Disposable {
42
42
  private readonly _onDidChange = new Emitter<ConfiguredIntegrationsChangeEvent>();
43
+ private storeConfiguredQueue: Promise<void> = Promise.resolve();
43
44
  get onDidChange(): Event<ConfiguredIntegrationsChangeEvent> {
44
45
  return this._onDidChange.event;
45
46
  }
@@ -129,7 +130,11 @@ export class ConfiguredIntegrationService implements Disposable {
129
130
  }));
130
131
  }
131
132
 
132
- await this.ctx.storage.store('integrations:configured', configured);
133
+ const pending = this.storeConfiguredQueue.then(() =>
134
+ this.ctx.storage.store('integrations:configured', configured),
135
+ );
136
+ this.storeConfiguredQueue = pending.catch(() => {});
137
+ await pending;
133
138
  }
134
139
 
135
140
  private async addOrUpdateConfigured(descriptor: ConfiguredIntegrationDescriptor): Promise<void> {
@@ -215,6 +215,11 @@ export type {
215
215
  } from './results.js';
216
216
  // Runtime enums — re-exported as values (not `export type`) so consumers can read their members.
217
217
  export { IssueFilter, PullRequestFilter } from './providerFilters.js';
218
+ // The filtered issue search's criteria model and the capability table that says which of it a provider honors.
219
+ // String unions and plain interfaces, so a consumer builds criteria without importing an internal subpath.
220
+ export type { IssueSearchCapabilities, IssueSearchCriteria, IssueSearchRelationship } from './providerFilters.js';
221
+ // The count-only issue probe's input and result shapes.
222
+ export type { IssueCountResult, IssueCountScope } from './reads/counts.js';
218
223
  // Cross-provider PR/issue state filters (string unions in the git models).
219
224
  export type { PullRequestStateFilter } from '../../git/models/pullRequest.js';
220
225
  export type { IssueStateFilter } from '../../git/models/issue.js';
@@ -1,5 +1,5 @@
1
1
  import type { Account } from '../../git/models/author.js';
2
- import type { IssueShape } from '../../git/models/issue.js';
2
+ import type { IssueSearchCriteria, IssueShape } from '../../git/models/issue.js';
3
3
  import type { PullRequest, PullRequestShape, PullRequestStateFilter } from '../../git/models/pullRequest.js';
4
4
  import type { GitRemote } from '../../git/models/remote.js';
5
5
  import type { RemoteProviderId } from '../../git/models/remoteProvider.js';
@@ -62,6 +62,7 @@ import { createApiClients } from './providers/apiClients.js';
62
62
  import type { GitHubApi } from './providers/github/github.js';
63
63
  import type {
64
64
  IssueFilter,
65
+ IssueSearchCapabilities,
65
66
  ProviderOrganization,
66
67
  ProviderReposInput,
67
68
  ProviderRepositoryShape,
@@ -71,12 +72,15 @@ import { providersMetadata } from './providers/models.js';
71
72
  import type { ProvidersApi } from './providers/providersApi.js';
72
73
  import { broadenIssues } from './reads/broaden.js';
73
74
  import type { RepositoryResolutionContext } from './reads/context.js';
75
+ import type { IssueCountResult, IssueCountScope } from './reads/counts.js';
76
+ import { countIssues } from './reads/counts.js';
74
77
  import { getSupportedFilters } from './reads/filters.js';
75
78
  import { listOrgs, listProjects, listRepos } from './reads/hierarchy.js';
76
79
  import { listIssuesPage } from './reads/issues.js';
77
80
  import { listIssueTrackerIssuesPage } from './reads/issueTracker.js';
78
81
  import { listPullRequestsPage } from './reads/pullRequests.js';
79
82
  import { resolveRepository } from './reads/resolveRepository.js';
83
+ import { searchIssuesPage } from './reads/searchIssues.js';
80
84
  import { sweepClosedPullRequests, sweepPullRequests } from './reads/sweeps.js';
81
85
  import { noConnectionWarning } from './reads/warnings.js';
82
86
  import type {
@@ -531,6 +535,7 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
531
535
  pullRequestsAccountWide: PullRequestFilter[];
532
536
  issues: IssueFilter[];
533
537
  issuesAccountWide: IssueFilter[];
538
+ issueSearch: IssueSearchCapabilities;
534
539
  } {
535
540
  return getSupportedFilters(providerId);
536
541
  }
@@ -1077,6 +1082,47 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1077
1082
  return listIssuesPage(this, options);
1078
1083
  }
1079
1084
 
1085
+ /**
1086
+ * Issues matching structured criteria over a repository/org scope, with no forced relationship to the current
1087
+ * user — see {@link IntegrationManager.searchIssuesPage} for the contract (mandatory scope, guaranteed
1088
+ * most-recently-updated-first ordering, and the result-ceiling omission that carries the total match count).
1089
+ */
1090
+ async searchIssuesPage(options: {
1091
+ providerId: IntegrationIds;
1092
+ repos?: ProviderReposInput;
1093
+ org?: string;
1094
+ criteria?: IssueSearchCriteria;
1095
+ page?: number;
1096
+ cursor?: string;
1097
+ itemsPerPage?: number;
1098
+ forceSync?: boolean;
1099
+ connectionId?: string;
1100
+ /**
1101
+ * Explicit self-managed host domain. Used only when the requested connection has no configured domain;
1102
+ * it must come from the trusted authentication configuration, not repository or remote data.
1103
+ */
1104
+ domain?: string;
1105
+ }): Promise<ProviderPagedResult<IssueShape>> {
1106
+ return searchIssuesPage(this, options);
1107
+ }
1108
+
1109
+ /**
1110
+ * How many issues match each scope, without fetching any — see {@link IntegrationManager.countIssues} for the
1111
+ * cost model, the per-scope isolation rules, and why `count: undefined` must not be rendered as zero.
1112
+ */
1113
+ async countIssues(options: {
1114
+ providerId: IntegrationIds;
1115
+ scopes: readonly IssueCountScope[];
1116
+ connectionId?: string;
1117
+ /**
1118
+ * Explicit self-managed host domain. Used only when the requested connection has no configured domain;
1119
+ * it must come from the trusted authentication configuration, not repository or remote data.
1120
+ */
1121
+ domain?: string;
1122
+ }): Promise<ProviderResult<IssueCountResult>> {
1123
+ return countIssues(this, options);
1124
+ }
1125
+
1080
1126
  /**
1081
1127
  * Reads the user's issues from an issue-tracker provider (Jira/Linear/Trello), whose issues live under
1082
1128
  * resource → project (not repos), so they can't go through {@link listIssuesPage} (git-host, repo-scoped).
@@ -1513,9 +1559,11 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1513
1559
 
1514
1560
  // Persist every account when the backend advertises per-connection identity (multi-account). This
1515
1561
  // is a strict no-op for backends that return a single, id-less connection per provider.
1516
- for (const [integrationId, connections] of connectionsById) {
1517
- await this.reconcileCloudConnections(integrationId, connections, forceConnect);
1518
- }
1562
+ await Promise.all(
1563
+ Array.from(connectionsById, ([integrationId, connections]) =>
1564
+ this.reconcileCloudConnections(integrationId, connections, forceConnect),
1565
+ ),
1566
+ );
1519
1567
 
1520
1568
  this.ctx.hooks?.connection?.onConnectedChanged?.({
1521
1569
  integrationIds: [...connectedIntegrations.values()],
@@ -1566,79 +1614,91 @@ export class IntegrationService implements Disposable, RepositoryResolutionConte
1566
1614
  const existingById = new Map(
1567
1615
  this.configuredIntegrationService.getConfigured(id, { cloud: true }).map(c => [c.id, c]),
1568
1616
  );
1569
- for (const connection of identified) {
1570
- // The wire `domain` is usually a full URL, though cloud providers can return a bare host.
1571
- // Self-managed integrations are keyed/constructed by host.
1572
- const host = hostFromDomain(connection.domain);
1573
-
1574
- // Self-managed connections are keyed by host, so an unparseable/empty domain would store the
1575
- // session and descriptor under an empty host producing ambiguous keys (`connected:<id>:`) that
1576
- // break later resolution and local-disconnect checks. Skip such a connection rather than corrupt
1577
- // state; cloud providers key off their canonical domain and are unaffected.
1578
- if (isGitSelfManagedHostIntegrationId(id) && !host) {
1579
- scope?.warn(`Skipping connection '${connection.id}' for ${id}: unresolved host from domain`);
1580
- continue;
1581
- }
1617
+ const preparedConnections = await Promise.all(
1618
+ identified.map(async connection => {
1619
+ // The wire `domain` is usually a full URL, though cloud providers can return a bare host.
1620
+ // Self-managed integrations are keyed/constructed by host.
1621
+ const host = hostFromDomain(connection.domain);
1622
+
1623
+ // Self-managed connections are keyed by host, so an unparseable/empty domain would store the
1624
+ // session and descriptor under an empty host producing ambiguous keys (`connected:<id>:`) that
1625
+ // break later resolution and local-disconnect checks. Skip such a connection rather than corrupt
1626
+ // state; cloud providers key off their canonical domain and are unaffected.
1627
+ if (isGitSelfManagedHostIntegrationId(id) && !host) {
1628
+ scope?.warn(`Skipping connection '${connection.id}' for ${id}: unresolved host from domain`);
1629
+ return undefined;
1630
+ }
1582
1631
 
1583
- // Don't resurrect a connection the user disconnected locally: a host "disconnect" only clears
1584
- // local state (the backend still lists the token), so without this the next non-forced sync would
1585
- // re-store the secret/config. A forced reconnect clears this flag (in the sync loop above) before
1586
- // reconcile runs, so it proceeds normally.
1587
- if (this.isLocallyDisconnected(id, host)) continue;
1588
-
1589
- syncEligibleIds.add(connection.id);
1590
-
1591
- // On a routine (non-forced) check-in, skip the token fetch + secret write for a connection we
1592
- // already have stored and that hasn't expired: nothing to refresh, so avoid the extra GK API
1593
- // traffic and secret churn. Still treat it as synced (so it doesn't trip the prune guard) and
1594
- // record its primary below. Forced syncs, new connections, and expired tokens fall through and
1595
- // fetch as before.
1596
- const cached = existingById.get(connection.id);
1597
- if (!forceConnect && cached != null && !isDescriptorExpired(cached)) {
1598
- syncedIds.add(connection.id);
1599
- if (connection.primary) {
1600
- const domain = isGitSelfManagedHostIntegrationId(id) ? host : undefined;
1601
- if (!syncedPrimaryIdsByDomain.has(domain)) {
1602
- syncedPrimaryIdsByDomain.set(domain, connection.id);
1603
- }
1632
+ // Don't resurrect a connection the user disconnected locally: a host "disconnect" only clears
1633
+ // local state (the backend still lists the token), so without this the next non-forced sync would
1634
+ // re-store the secret/config. A forced reconnect clears this flag (in the sync loop above) before
1635
+ // reconcile runs, so it proceeds normally.
1636
+ if (this.isLocallyDisconnected(id, host)) return undefined;
1637
+
1638
+ syncEligibleIds.add(connection.id);
1639
+
1640
+ // On a routine (non-forced) check-in, skip the token fetch + secret write for a connection we
1641
+ // already have stored and that hasn't expired: nothing to refresh, so avoid the extra GK API
1642
+ // traffic and secret churn. Still treat it as synced (so it doesn't trip the prune guard) and
1643
+ // record its primary below. Forced syncs, new connections, and expired tokens fall through and
1644
+ // fetch as before.
1645
+ const cached = existingById.get(connection.id);
1646
+ if (!forceConnect && cached != null && !isDescriptorExpired(cached)) {
1647
+ return { kind: 'cached' as const, connection: connection, host: host };
1604
1648
  }
1605
- continue;
1606
- }
1607
1649
 
1608
- try {
1609
- const session = await cloudIntegrations.getConnectionSession(id, undefined, connection.id);
1610
- if (session == null) continue;
1611
-
1612
- let providerSession = toProviderSession(id, connection, session, host);
1613
-
1614
- // Resolve a human-readable account handle with the same precedence as the gk CLI:
1615
- // (1) the value the backend put on the connection, (2) a previously-resolved name cached in
1616
- // our configured store (keyed by connection id), (3) a live provider-API lookup. This keeps
1617
- // provider round-trips to the first sight of a connection; degrade to undefined on failure.
1618
- const existing = existingById.get(connection.id);
1619
- const accountName =
1620
- normalizeAccountName(connection.accountName) ??
1621
- normalizeAccountName(existing?.accountName) ??
1622
- (await this.resolveAccountName(id, host, providerSession));
1623
- if (accountName != null) {
1624
- providerSession = {
1625
- ...providerSession,
1626
- account: { ...providerSession.account, label: accountName },
1650
+ try {
1651
+ const session = await cloudIntegrations.getConnectionSession(id, undefined, connection.id);
1652
+ if (session == null) return undefined;
1653
+
1654
+ let providerSession = toProviderSession(id, connection, session, host);
1655
+
1656
+ // Resolve a human-readable account handle with the same precedence as the gk CLI:
1657
+ // (1) the value the backend put on the connection, (2) a previously-resolved name cached in
1658
+ // our configured store (keyed by connection id), (3) a live provider-API lookup. This keeps
1659
+ // provider round-trips to the first sight of a connection; degrade to undefined on failure.
1660
+ const existing = existingById.get(connection.id);
1661
+ const accountName =
1662
+ normalizeAccountName(connection.accountName) ??
1663
+ normalizeAccountName(existing?.accountName) ??
1664
+ (await this.resolveAccountName(id, host, providerSession));
1665
+ if (accountName != null) {
1666
+ providerSession = {
1667
+ ...providerSession,
1668
+ account: { ...providerSession.account, label: accountName },
1669
+ };
1670
+ }
1671
+
1672
+ return {
1673
+ kind: 'fetched' as const,
1674
+ connection: connection,
1675
+ host: host,
1676
+ providerSession: providerSession,
1627
1677
  };
1678
+ } catch (ex) {
1679
+ scope?.warn(
1680
+ `Failed to sync connection '${connection.id}' for ${id}: ${ex instanceof Error ? ex.message : String(ex)}`,
1681
+ );
1682
+ return undefined;
1628
1683
  }
1684
+ }),
1685
+ );
1629
1686
 
1630
- await this.configuredIntegrationService.storeSession(id, providerSession);
1631
- syncedIds.add(connection.id);
1632
- if (connection.primary) {
1633
- const domain = isGitSelfManagedHostIntegrationId(id) ? host : undefined;
1634
- if (!syncedPrimaryIdsByDomain.has(domain)) {
1635
- syncedPrimaryIdsByDomain.set(domain, connection.id);
1636
- }
1687
+ // The remote work above is independent, but session persistence remains ordered because storage
1688
+ // implementations may update a shared configured-connections collection with read-modify-write.
1689
+ for (const prepared of preparedConnections) {
1690
+ if (prepared == null) continue;
1691
+
1692
+ if (prepared.kind === 'fetched') {
1693
+ await this.configuredIntegrationService.storeSession(id, prepared.providerSession);
1694
+ }
1695
+
1696
+ syncedIds.add(prepared.connection.id);
1697
+ if (prepared.connection.primary) {
1698
+ const domain = isGitSelfManagedHostIntegrationId(id) ? prepared.host : undefined;
1699
+ if (!syncedPrimaryIdsByDomain.has(domain)) {
1700
+ syncedPrimaryIdsByDomain.set(domain, prepared.connection.id);
1637
1701
  }
1638
- } catch (ex) {
1639
- scope?.warn(
1640
- `Failed to sync connection '${connection.id}' for ${id}: ${ex instanceof Error ? ex.message : String(ex)}`,
1641
- );
1642
1702
  }
1643
1703
  }
1644
1704
 
@@ -1,10 +1,11 @@
1
- import type { IssueShape } from '../../git/models/issue.js';
1
+ import type { IssueSearchCriteria, IssueShape } from '../../git/models/issue.js';
2
2
  import type { PullRequestShape, PullRequestStateFilter } from '../../git/models/pullRequest.js';
3
3
  import type { Event } from '../../utils/event.js';
4
4
  import type { ConfiguredIntegrationsChangeEvent } from './authentication/configuredIntegrationService.js';
5
5
  import type { ConfiguredIntegrationDescriptor } from './authentication/models.js';
6
6
  import type { IntegrationIds } from './constants.js';
7
- import type { IssueFilter, PullRequestFilter } from './providerFilters.js';
7
+ import type { IssueFilter, IssueSearchCapabilities, PullRequestFilter } from './providerFilters.js';
8
+ import type { IssueCountResult, IssueCountScope } from './reads/counts.js';
8
9
  import type {
9
10
  ConnectionStateChangeEvent,
10
11
  ProviderBroadenResult,
@@ -200,6 +201,12 @@ export interface IntegrationManager {
200
201
  pullRequestsAccountWide?: PullRequestFilter[];
201
202
  issues: IssueFilter[];
202
203
  issuesAccountWide: IssueFilter[];
204
+ /**
205
+ * What {@link searchIssuesPage} (and {@link countIssues}, over the same criteria) can express for this
206
+ * provider. Always present: a provider with no filtered issue search reports an empty `relationships` and
207
+ * all-false flags, which is the signal to hide that surface rather than to hide individual chips.
208
+ */
209
+ issueSearch: IssueSearchCapabilities;
203
210
  };
204
211
  /** Forces an authoritative cloud connection refresh. Rejects if the backend connection list cannot be read. */
205
212
  refreshConnections(): Promise<void>;
@@ -306,6 +313,85 @@ export interface IntegrationManager {
306
313
  /** Self-managed host domain fallback; see {@link ProviderSweepTarget.domain}. */
307
314
  domain?: string;
308
315
  }): Promise<ProviderPagedResult<IssueShape>>;
316
+ /**
317
+ * Issues matching structured criteria over a repository/org scope, with NO forced relationship to the current
318
+ * user — the issue counterpart of the PR search, and the read to use for "every issue in these repos matching
319
+ * X" rather than "my issues".
320
+ *
321
+ * Why this and not {@link listIssuesPage}: that read is either bound to the user's own relationships
322
+ * (account-wide) or routed through the SDK's repo-scoped read, whose over-limit recovery walk can spend up to
323
+ * 128 sequential requests and still return an incomplete set. This one is a single request per page.
324
+ *
325
+ * Three parts of the contract worth reading before calling:
326
+ *
327
+ * - **Scope is mandatory.** Pass `repos`, `org`, or a user relationship (`authored`/`assigned`/`mentioned`).
328
+ * `any-assignee`/`unassigned` do NOT scope anything — either alone matches every such issue on the host —
329
+ * so a call carrying only those is refused (warning + `fetchFailed`).
330
+ * - **Ordering is always most-recently-updated-first**, not an option. A "show the N most recent" policy at
331
+ * the provider's result ceiling is only correct under a guaranteed order.
332
+ * - **At the result ceiling the read SUCCEEDS.** It reports an omission carrying `totalCount` (how many
333
+ * matched) and `limit` (how many are reachable) with `recovery: 'none'`, so a consumer can say "19.240
334
+ * matched, showing the 1.000 most recent" and know not to offer a "load more". It never falls back to a
335
+ * per-repository recovery walk.
336
+ *
337
+ * Check `getSupportedFilters().issueSearch` first: a provider with no filtered issue search reports empty
338
+ * relationships (and this read refuses), and a criterion it can't express refuses the whole read rather than
339
+ * serving a wider result than asked for.
340
+ */
341
+ searchIssuesPage(options: {
342
+ providerId: IntegrationIds;
343
+ /** Repositories to search. Combines with `org`; both constrain the same query. */
344
+ repos?: ProviderRepositoriesInput;
345
+ /** Organization/account to search. Combines with `repos`. */
346
+ org?: string;
347
+ criteria?: IssueSearchCriteria;
348
+ /**
349
+ * Requested 1-based page. This read is cursor-only, so without a `cursor` reaching page N costs O(N)
350
+ * upstream requests; pass the previous page's cursor to make it exactly one.
351
+ */
352
+ page?: number;
353
+ cursor?: string;
354
+ /**
355
+ * Page size PER RELATIONSHIP, not per page. Each requested relationship is its own provider query, so a
356
+ * page of a 2-relationship search returns up to `2 × itemsPerPage` items before deduplication — and fewer
357
+ * than that when the two overlap. `page.itemsPerPage` reports what actually came back, so size the UI off
358
+ * that rather than off this. A provider may also cap it below what is asked for.
359
+ */
360
+ itemsPerPage?: number;
361
+ forceSync?: boolean;
362
+ connectionId?: string;
363
+ /** Self-managed host domain fallback; see {@link ProviderSweepTarget.domain}. */
364
+ domain?: string;
365
+ }): Promise<ProviderPagedResult<IssueShape>>;
366
+ /**
367
+ * How many issues MATCH each scope, without fetching any of them — the probe behind a "this will fetch ~N
368
+ * issues" preview, and behind a live count next to a filter the user hasn't applied yet.
369
+ *
370
+ * Cheap by design: every scope that can share a request does, and no issue data crosses the wire (measured
371
+ * against GitHub, 30 counts are a single rate-limit point). Still a network request per batch, so debounce and
372
+ * cache it if it's driven from UI state.
373
+ *
374
+ * A separate method rather than a flag on {@link searchIssuesPage} because transferring zero issues is the
375
+ * whole point: a `countOnly` read would hand back a paged result whose `items`/`cursor`/`hasMore` are all
376
+ * meaningless.
377
+ *
378
+ * Results are echoed under the caller's own `key`, so no positional matching is needed. Per-scope isolation is
379
+ * the rule: a scope refused for its own reasons, or a batch that failed upstream, warns and drops only its own
380
+ * scopes (with `fetchFailed` set) while every other count still comes back.
381
+ *
382
+ * `count: undefined` means the provider didn't report one — NOT zero, which is a real answer. Render the
383
+ * difference: showing an unknown count as 0 tells the user a filter matches nothing when it may match
384
+ * thousands. A provider that can't count at all (only GitHub/GHE can today) refuses the probe outright rather
385
+ * than returning fabricated numbers.
386
+ */
387
+ countIssues(options: {
388
+ providerId: IntegrationIds;
389
+ /** Each needs its own scope, and each requested relationship its own entry — see {@link IssueCountScope}. */
390
+ scopes: readonly IssueCountScope[];
391
+ connectionId?: string;
392
+ /** Self-managed host domain fallback; see {@link ProviderSweepTarget.domain}. */
393
+ domain?: string;
394
+ }): Promise<ProviderResult<IssueCountResult>>;
309
395
  /** Issue trackers are cloud-only, so this read takes no `domain`. */
310
396
  listIssueTrackerIssuesPage(options: {
311
397
  providerId: IntegrationIds;