@gitkraken/core-gitlens 0.3.0 → 0.3.1

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 (170) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/dist/git/cache.d.ts +12 -6
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +40 -39
  5. package/dist/git/cache.js.map +1 -1
  6. package/dist/git/context.d.ts +19 -0
  7. package/dist/git/context.d.ts.map +1 -1
  8. package/dist/git/context.js.map +1 -1
  9. package/dist/git/features.d.ts +1 -1
  10. package/dist/git/features.d.ts.map +1 -1
  11. package/dist/git/features.js +1 -0
  12. package/dist/git/features.js.map +1 -1
  13. package/dist/git/models/graph.d.ts +96 -0
  14. package/dist/git/models/graph.d.ts.map +1 -1
  15. package/dist/git/models/graph.js +24 -1
  16. package/dist/git/models/graph.js.map +1 -1
  17. package/dist/git/models/remoteProvider.d.ts +1 -0
  18. package/dist/git/models/remoteProvider.d.ts.map +1 -1
  19. package/dist/git/models/remoteProvider.js.map +1 -1
  20. package/dist/git/models/repository.d.ts +21 -12
  21. package/dist/git/models/repository.d.ts.map +1 -1
  22. package/dist/git/models/repository.js +70 -84
  23. package/dist/git/models/repository.js.map +1 -1
  24. package/dist/git/models/statusFile.d.ts +9 -0
  25. package/dist/git/models/statusFile.d.ts.map +1 -1
  26. package/dist/git/models/statusFile.js +11 -0
  27. package/dist/git/models/statusFile.js.map +1 -1
  28. package/dist/git/providers/commits.d.ts +4 -0
  29. package/dist/git/providers/commits.d.ts.map +1 -1
  30. package/dist/git/providers/config.d.ts +1 -3
  31. package/dist/git/providers/config.d.ts.map +1 -1
  32. package/dist/git/providers/operations.d.ts +39 -13
  33. package/dist/git/providers/operations.d.ts.map +1 -1
  34. package/dist/git/remotes/custom.d.ts +2 -0
  35. package/dist/git/remotes/custom.d.ts.map +1 -1
  36. package/dist/git/remotes/custom.js +20 -0
  37. package/dist/git/remotes/custom.js.map +1 -1
  38. package/dist/git/utils/contributor.utils.d.ts +3 -0
  39. package/dist/git/utils/contributor.utils.d.ts.map +1 -1
  40. package/dist/git/utils/contributor.utils.js +22 -0
  41. package/dist/git/utils/contributor.utils.js.map +1 -1
  42. package/dist/git/utils/reachability.utils.d.ts +37 -0
  43. package/dist/git/utils/reachability.utils.d.ts.map +1 -0
  44. package/dist/git/utils/reachability.utils.js +96 -0
  45. package/dist/git/utils/reachability.utils.js.map +1 -0
  46. package/dist/git/watching/watchService.d.ts +4 -2
  47. package/dist/git/watching/watchService.d.ts.map +1 -1
  48. package/dist/git/watching/watchService.js +8 -3
  49. package/dist/git/watching/watchService.js.map +1 -1
  50. package/dist/git/watching/watchSession.d.ts +14 -12
  51. package/dist/git/watching/watchSession.d.ts.map +1 -1
  52. package/dist/git/watching/watchSession.js +56 -55
  53. package/dist/git/watching/watchSession.js.map +1 -1
  54. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  55. package/dist/git-cli/providers/branches.js +1 -0
  56. package/dist/git-cli/providers/branches.js.map +1 -1
  57. package/dist/git-cli/providers/commits.d.ts +1 -0
  58. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  59. package/dist/git-cli/providers/commits.js +12 -0
  60. package/dist/git-cli/providers/commits.js.map +1 -1
  61. package/dist/git-cli/providers/config.d.ts +8 -4
  62. package/dist/git-cli/providers/config.d.ts.map +1 -1
  63. package/dist/git-cli/providers/config.js +62 -39
  64. package/dist/git-cli/providers/config.js.map +1 -1
  65. package/dist/git-cli/providers/diff.d.ts.map +1 -1
  66. package/dist/git-cli/providers/diff.js +1 -1
  67. package/dist/git-cli/providers/diff.js.map +1 -1
  68. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  69. package/dist/git-cli/providers/graph.js +132 -14
  70. package/dist/git-cli/providers/graph.js.map +1 -1
  71. package/dist/git-cli/providers/operations.d.ts +6 -5
  72. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  73. package/dist/git-cli/providers/operations.js +97 -95
  74. package/dist/git-cli/providers/operations.js.map +1 -1
  75. package/dist/git-cli/providers/stash.d.ts.map +1 -1
  76. package/dist/git-cli/providers/stash.js +20 -2
  77. package/dist/git-cli/providers/stash.js.map +1 -1
  78. package/dist/git-cli/providers/status.d.ts.map +1 -1
  79. package/dist/git-cli/providers/status.js +5 -2
  80. package/dist/git-cli/providers/status.js.map +1 -1
  81. package/dist/ipc/discovery.d.ts +22 -0
  82. package/dist/ipc/discovery.d.ts.map +1 -1
  83. package/dist/ipc/discovery.js +111 -2
  84. package/dist/ipc/discovery.js.map +1 -1
  85. package/dist/plus/agents/providers/claudeCodeProvider.d.ts +73 -20
  86. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -1
  87. package/dist/plus/agents/providers/claudeCodeProvider.js +394 -88
  88. package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -1
  89. package/dist/plus/agents/types.d.ts +62 -11
  90. package/dist/plus/agents/types.d.ts.map +1 -1
  91. package/dist/plus/ai/models/model.d.ts +1 -1
  92. package/dist/plus/ai/models/model.d.ts.map +1 -1
  93. package/dist/plus/ai/models/model.js.map +1 -1
  94. package/dist/plus/ai/models/provider.d.ts +3 -0
  95. package/dist/plus/ai/models/provider.d.ts.map +1 -1
  96. package/dist/plus/ai/providers/gitkrakenProvider.d.ts +1 -1
  97. package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
  98. package/dist/plus/ai/providers/gitkrakenProvider.js +4 -1
  99. package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
  100. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +4 -3
  101. package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
  102. package/dist/plus/ai/providers/openAICompatibleProviderBase.js +9 -6
  103. package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
  104. package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
  105. package/dist/plus/ai/utils/ai.utils.js +2 -0
  106. package/dist/plus/ai/utils/ai.utils.js.map +1 -1
  107. package/dist/plus/git-github/api/github.d.ts +2 -0
  108. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  109. package/dist/plus/git-github/api/github.js +93 -6
  110. package/dist/plus/git-github/api/github.js.map +1 -1
  111. package/dist/plus/git-github/providers/github/refs.js +1 -1
  112. package/dist/utils/date.d.ts.map +1 -1
  113. package/dist/utils/date.js +6 -0
  114. package/dist/utils/date.js.map +1 -1
  115. package/dist/utils/debounce.js +1 -1
  116. package/dist/utils/iterable.d.ts +7 -1
  117. package/dist/utils/iterable.d.ts.map +1 -1
  118. package/dist/utils/iterable.js +12 -4
  119. package/dist/utils/iterable.js.map +1 -1
  120. package/dist/utils/object.d.ts +22 -1
  121. package/dist/utils/object.d.ts.map +1 -1
  122. package/dist/utils/object.js +56 -3
  123. package/dist/utils/object.js.map +1 -1
  124. package/dist/utils/paging.js +14 -15
  125. package/dist/utils/paging.js.map +1 -1
  126. package/dist/utils/promiseCache.d.ts.map +1 -1
  127. package/dist/utils/promiseCache.js +12 -4
  128. package/dist/utils/promiseCache.js.map +1 -1
  129. package/dist/utils/string.js +1 -1
  130. package/package.json +1 -1
  131. package/src/git/cache.ts +58 -70
  132. package/src/git/context.ts +21 -0
  133. package/src/git/features.ts +2 -0
  134. package/src/git/models/graph.ts +97 -0
  135. package/src/git/models/remoteProvider.ts +1 -0
  136. package/src/git/models/repository.ts +84 -93
  137. package/src/git/models/statusFile.ts +12 -0
  138. package/src/git/providers/commits.ts +4 -0
  139. package/src/git/providers/config.ts +1 -5
  140. package/src/git/providers/operations.ts +42 -22
  141. package/src/git/remotes/custom.ts +23 -0
  142. package/src/git/utils/contributor.utils.ts +24 -0
  143. package/src/git/utils/reachability.utils.ts +109 -0
  144. package/src/git/watching/watchService.ts +9 -4
  145. package/src/git/watching/watchSession.ts +61 -55
  146. package/src/git-cli/providers/branches.ts +1 -0
  147. package/src/git-cli/providers/commits.ts +24 -0
  148. package/src/git-cli/providers/config.ts +63 -49
  149. package/src/git-cli/providers/diff.ts +2 -1
  150. package/src/git-cli/providers/graph.ts +145 -14
  151. package/src/git-cli/providers/operations.ts +118 -130
  152. package/src/git-cli/providers/stash.ts +20 -2
  153. package/src/git-cli/providers/status.ts +11 -3
  154. package/src/ipc/discovery.ts +132 -2
  155. package/src/plus/agents/providers/claudeCodeProvider.ts +430 -90
  156. package/src/plus/agents/types.ts +59 -11
  157. package/src/plus/ai/models/model.ts +1 -0
  158. package/src/plus/ai/models/provider.ts +7 -1
  159. package/src/plus/ai/providers/gitkrakenProvider.ts +4 -0
  160. package/src/plus/ai/providers/openAICompatibleProviderBase.ts +21 -4
  161. package/src/plus/ai/utils/ai.utils.ts +2 -0
  162. package/src/plus/git-github/api/github.ts +120 -8
  163. package/src/plus/git-github/providers/github/refs.ts +1 -1
  164. package/src/utils/date.ts +5 -0
  165. package/src/utils/debounce.ts +1 -1
  166. package/src/utils/iterable.ts +14 -3
  167. package/src/utils/object.ts +55 -4
  168. package/src/utils/paging.ts +14 -14
  169. package/src/utils/promiseCache.ts +24 -16
  170. package/src/utils/string.ts +1 -1
@@ -135,22 +135,46 @@ export interface AgentSession {
135
135
  * identity. For "what repo is this session in", use {@link commonPath}. */
136
136
  readonly workspacePath?: string;
137
137
  readonly cwd?: string;
138
+ /** The session's `cwd` at first observation. Set once and never overwritten — captures the
139
+ * agent's launch directory so consumers can detect drift (e.g. an agent that `cd`'d into a
140
+ * sibling worktree). Pairs with {@link initialWorktreePath} / {@link initialCommonPath}; for
141
+ * the live cwd, see {@link cwd}. */
142
+ readonly initialCwd?: string;
143
+ /** The session's `worktreePath` at first successful git-info resolution. Set once and never
144
+ * overwritten — compare against {@link worktreePath} to detect cross-worktree drift. Undefined
145
+ * if the session has never resolved into a git repo. */
146
+ readonly initialWorktreePath?: string;
147
+ /** The session's `commonPath` at first successful git-info resolution. Set once and never
148
+ * overwritten — compare against {@link commonPath} to detect cross-repo drift. Undefined if
149
+ * the session has never resolved into a git repo. */
150
+ readonly initialCommonPath?: string;
138
151
  readonly planFile?: string;
139
152
  readonly isInWorkspace: boolean;
140
153
  readonly lastPrompt?: string;
141
154
  readonly firstPrompt?: string;
142
- /** Absolute paths of files targeted by in-flight file-editing tool calls (Edit/Write/MultiEdit/
143
- * NotebookEdit). Populated on PreToolUse, drained on PostToolUse/PermissionDenied, cleared on
144
- * Stop/SessionEnd. Empty/undefined when no file-editing tool is active.
155
+ /** Per-file activity record covering both read-class (Read/NotebookRead) and edit-class
156
+ * (Edit/Write/MultiEdit/NotebookEdit) tool calls. Populated on PreToolUse; each path is retained
157
+ * and fades for the configured decay window measured from its own last PostToolUse — the tail
158
+ * survives the turn-end Stop (which only drops the live `editing`/`reading` flags) and is fully
159
+ * cleared only on SessionEnd or once the decay window elapses.
145
160
  *
146
- * Mutable array form so `Shape<AgentSession>` projects it as `string[]` (the `Shape<>` type
147
- * mangles `readonly T[]` into a mapped object that loses its iterator). Treat as immutable. */
148
- currentFiles?: string[];
149
- /** Absolute paths of files the agent recently *read* (Read/NotebookRead). Populated on
150
- * PreToolUse, drained on PostToolUse/PermissionDenied with the same cooldown as
151
- * `currentFiles`. Distinct from `currentFiles` which tracks write-class tools so consumers can
152
- * visualize "looking at" vs "working on" differently. Treat as immutable. */
153
- currentReads?: string[];
161
+ * A sub-agent's file activity appears on its PARENT's `fileActivity`, not the sub-agent's own:
162
+ * the GK CLI keys a sub-agent's tool events under the parent session id (with `agentId` set), so
163
+ * they accumulate in the parent's bookkeeping directly. Consumers never need to recurse into
164
+ * `subagents[]`; sub-agent `AgentSession` objects carry `fileActivity: undefined`.
165
+ *
166
+ * Field semantics:
167
+ * - `path`: absolute path on the agent's filesystem.
168
+ * - `readAt` / `editedAt`: milliseconds since the last PreToolUse for that kind, computed by
169
+ * the host at serialization time (relative — avoids clock-skew). Omitted when never touched
170
+ * by that kind within the window.
171
+ * - `reading` / `editing`: `true` when at least one tool of that kind is in flight on this
172
+ * path right now (refcount > 0 in the host bookkeeping). Omitted when false to keep the
173
+ * wire minimal.
174
+ *
175
+ * Mutable array form so `Shape<AgentSession>` projects it cleanly (the `Shape<>` type mangles
176
+ * `readonly T[]` into a mapped object that loses its iterator). Treat as immutable. */
177
+ fileActivity?: { path: string; readAt?: number; editedAt?: number; reading?: true; editing?: true }[];
154
178
  /** `true` when the session was discovered via peer IPC sync (i.e. another GitLens window hosts
155
179
  * the agent's hook flow and Claude Code extension panel). Locally-owned sessions leave this
156
180
  * unset. The dispatcher uses this to route opens through the peer's IPC route + an OS-level
@@ -186,6 +210,11 @@ export interface AgentSessionProvider extends UnifiedDisposable {
186
210
  stop(): void;
187
211
  updateWorkspacePaths?(workspacePaths: string[]): void;
188
212
 
213
+ /** Pushed by the host from its cached agent detection. Lets the provider gate its reconciliation
214
+ * poll (the CLI `list-sessions` call) so an idle window with no sessions and no installed hooks
215
+ * doesn't spawn the CLI every interval. */
216
+ setClaudeHooksInstalled?(installed: boolean): void;
217
+
189
218
  /** Resolves a pending permission. Returns `true` when the resolve was routed (the local IPC
190
219
  * owns the session's pending entry); `false` when no local entry exists (typically a peer-
191
220
  * discovered session owned by another GitLens window). Callers use this to give the user
@@ -231,6 +260,13 @@ export interface AgentProviderCallbacks {
231
260
  /** Host-supplied IPC service. Required for agents to receive hook events from the GK CLI. */
232
261
  ipc: IpcRegistrar;
233
262
 
263
+ /** Returns the current "activity decay" window in milliseconds — the cooldown between a tool
264
+ * call finishing and the file being dropped from `AgentSession.fileActivity`. Optional — when
265
+ * omitted the provider falls back to its built-in default (5 min). Read by the provider at
266
+ * schedule time so changes to the host setting take effect on the next tool call without
267
+ * needing a re-wiring step. */
268
+ getActivityDecayMs?(): number;
269
+
234
270
  /** Report that an agent session started. No-op if the host has no telemetry. */
235
271
  onSessionStarted?(provider: string): void;
236
272
 
@@ -240,6 +276,18 @@ export interface AgentProviderCallbacks {
240
276
  /** Report that a permission request was resolved. No-op if the host has no telemetry. */
241
277
  onPermissionResolved?(info: { provider: string; tool: string; decision: PermissionDecision }): void;
242
278
 
279
+ /** Report that a reconciliation poll found the polled session set drift from what the live IPC
280
+ * path had already tracked — `discovered` sessions the poll saw but we weren't tracking,
281
+ * `missing` sessions we track that the poll no longer reports alive. Ideally always zero. No-op
282
+ * if the host has no telemetry. */
283
+ onSyncDiscrepancy?(info: {
284
+ provider: string;
285
+ discovered: number;
286
+ missing: number;
287
+ polled: number;
288
+ tracked: number;
289
+ }): void;
290
+
243
291
  /** Notify the host that a branch has agent activity. */
244
292
  onBranchAgentActivity?(cwd: string): void;
245
293
 
@@ -28,6 +28,7 @@ export type AIActionType =
28
28
  | 'generate-changelog'
29
29
  | `generate-create-${'cloudPatch' | 'codeSuggestion' | 'pullRequest'}`
30
30
  | 'generate-commits'
31
+ | 'conflict-resolution'
31
32
  | 'generate-searchQuery';
32
33
 
33
34
  export interface AIProviderDescriptor<T extends AIProviders = AIProviders> {
@@ -47,6 +47,12 @@ export interface AIProvider<Provider extends AIProviders = AIProviders> extends
47
47
  model: AIModel<Provider>,
48
48
  apiKey: string,
49
49
  getMessages: (maxInputTokens: number, retries: number) => Promise<AIChatMessage[]>,
50
- options: { signal: AbortSignal; modelOptions?: { outputTokens?: number; temperature?: number } },
50
+ options: {
51
+ signal: AbortSignal;
52
+ modelOptions?: { outputTokens?: number; temperature?: number };
53
+ /** Opaque session ID the GitKraken provider sends as `GK-Conversation-ID` so the backend
54
+ * charges its per-feature fee once per session; ignored by all other providers. */
55
+ conversationId?: string;
56
+ },
51
57
  ): Promise<AIProviderResponse<void> | undefined>;
52
58
  }
@@ -87,11 +87,15 @@ export class GitKrakenProvider extends OpenAICompatibleProviderBase<typeof provi
87
87
  apiKey: string,
88
88
  _model: AIModel<typeof provider.id>,
89
89
  _url: string,
90
+ conversationId?: string,
90
91
  ): Record<string, string> {
91
92
  return {
92
93
  Accept: 'application/json',
93
94
  Authorization: `Bearer ${apiKey}`,
94
95
  'GK-Action': action,
96
+ // Scopes the backend's once-per-conversation feature fee — without it every request in a
97
+ // multi-call session (e.g. conflict resolution) is charged the full flat fee.
98
+ ...(conversationId ? { 'GK-Conversation-ID': conversationId } : {}),
95
99
  };
96
100
  }
97
101
 
@@ -61,6 +61,9 @@ export abstract class OpenAICompatibleProviderBase<T extends AIProviders> implem
61
61
  apiKey: string,
62
62
  _model: AIModel<T>,
63
63
  _url: string,
64
+ // Deliberately unused here: only the GitKraken provider forwards the conversation ID (as
65
+ // `GK-Conversation-ID`); it must never reach third-party APIs.
66
+ _conversationId?: string,
64
67
  ): Record<string, string> | Promise<Record<string, string>> {
65
68
  return {
66
69
  Accept: 'application/json',
@@ -74,12 +77,24 @@ export abstract class OpenAICompatibleProviderBase<T extends AIProviders> implem
74
77
  model: AIModel<T>,
75
78
  apiKey: string,
76
79
  getMessages: (maxInputTokens: number, retries: number) => Promise<AIChatMessage[]>,
77
- options: { signal: AbortSignal; modelOptions?: { outputTokens?: number; temperature?: number } },
80
+ options: {
81
+ signal: AbortSignal;
82
+ modelOptions?: { outputTokens?: number; temperature?: number };
83
+ conversationId?: string;
84
+ },
78
85
  ): Promise<AIProviderResponse<void> | undefined> {
79
86
  using scope = maybeStartScopedLogger(`${getLoggableName(this)}.sendRequest`);
80
87
 
81
88
  try {
82
- const result = await this.fetch(action, model, apiKey, getMessages, options.modelOptions, options.signal);
89
+ const result = await this.fetch(
90
+ action,
91
+ model,
92
+ apiKey,
93
+ getMessages,
94
+ options.modelOptions,
95
+ options.signal,
96
+ options.conversationId,
97
+ );
83
98
  return result;
84
99
  } catch (ex) {
85
100
  if (isCancellationError(ex)) {
@@ -104,6 +119,7 @@ export abstract class OpenAICompatibleProviderBase<T extends AIProviders> implem
104
119
  messages: (maxInputTokens: number, retries: number) => Promise<AIChatMessage[]>,
105
120
  modelOptions?: { outputTokens?: number; temperature?: number },
106
121
  signal?: AbortSignal,
122
+ conversationId?: string,
107
123
  ): Promise<AIProviderResponse<void>> {
108
124
  let retries = 0;
109
125
  let maxInputTokens = model.maxTokens.input;
@@ -123,7 +139,7 @@ export abstract class OpenAICompatibleProviderBase<T extends AIProviders> implem
123
139
  ),
124
140
  };
125
141
 
126
- const rsp = await this.fetchCore(action, model, apiKey, request, signal);
142
+ const rsp = await this.fetchCore(action, model, apiKey, request, signal, conversationId);
127
143
  if (!rsp.ok) {
128
144
  const result = await this.handleFetchFailure(rsp, action, model, retries, maxInputTokens);
129
145
  if (result.retry) {
@@ -207,6 +223,7 @@ export abstract class OpenAICompatibleProviderBase<T extends AIProviders> implem
207
223
  apiKey: string,
208
224
  request: object,
209
225
  signal: AbortSignal | undefined,
226
+ conversationId?: string,
210
227
  ): Promise<Response> {
211
228
  const url = this.getUrl(model);
212
229
  if (!url) {
@@ -215,7 +232,7 @@ export abstract class OpenAICompatibleProviderBase<T extends AIProviders> implem
215
232
 
216
233
  try {
217
234
  return await this.context.fetch(url, {
218
- headers: await this.getHeaders(action, apiKey, model, url),
235
+ headers: await this.getHeaders(action, apiKey, model, url, conversationId),
219
236
  method: 'POST',
220
237
  body: JSON.stringify(request),
221
238
  signal: signal,
@@ -20,6 +20,8 @@ export function getActionName(action: AIActionType): string {
20
20
  return 'Create Pull Request Details (Preview)';
21
21
  case 'generate-commits':
22
22
  return 'Generate Commits (Preview)';
23
+ case 'conflict-resolution':
24
+ return 'Resolve Conflicts with AI (Preview)';
23
25
  case 'generate-searchQuery':
24
26
  return 'Generate Search Query (Preview)';
25
27
  }
@@ -74,6 +74,46 @@ interface CancellationToken {
74
74
  const emptyPagedResult: PagedResult<any> = Object.freeze({ values: [] });
75
75
  const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] });
76
76
 
77
+ // Transient gateway/network failures (e.g. an upstream `502 Bad Gateway`) are worth a few quick
78
+ // retries before surfacing to the caller. octokit provides no built-in retry for the standalone
79
+ // `request`/`graphql` functions we use (its retry/throttle plugins only attach to the `Octokit`
80
+ // class), so we retry here — but only for idempotent reads (REST GET/HEAD, GraphQL queries),
81
+ // never for mutations.
82
+ const maxRequestRetries = 2;
83
+ const requestRetryBaseDelay = 300; // ms
84
+ const requestRetryMaxDelay = 2000; // ms
85
+
86
+ function isRetryableTransientError(ex: unknown): ex is RequestError {
87
+ // An aborted request is rethrown as the original `AbortError` (not a `RequestError`), so it is
88
+ // excluded here. octokit maps a fetch/network failure to a `RequestError` with status 500 and
89
+ // no response — those, along with real gateway statuses, are transient and safe to retry.
90
+ if (!(ex instanceof RequestError)) return false;
91
+
92
+ switch (ex.status) {
93
+ case 502: // Bad Gateway
94
+ case 503: // Service Unavailable
95
+ case 504: // Gateway Timeout
96
+ return true;
97
+ case 500: // Internal Server Error — only the network-failure variant (no response)
98
+ return ex.response == null;
99
+ default:
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function getRequestRetryDelay(attempt: number): number {
105
+ // Exponential backoff with equal jitter, capped — spreads retries so a brief upstream blip
106
+ // isn't hammered by every in-flight request landing on the same schedule.
107
+ const backoff = Math.min(requestRetryMaxDelay, requestRetryBaseDelay * 2 ** (attempt - 1));
108
+ return Math.round(backoff / 2 + Math.random() * (backoff / 2));
109
+ }
110
+
111
+ // Matches a GraphQL document whose operation is definitely a read query — the `query` keyword as
112
+ // the first significant token, after any leading whitespace or `#` comment lines. We retry only
113
+ // when this matches; a mutation (even one prefixed by a comment), a subscription, or anything we
114
+ // can't classify is left non-retryable so a transient failure can never re-run a mutation.
115
+ const graphqlReadQueryRegex = /^(?:\s|#[^\n]*\n?)*query\b/;
116
+
77
117
  const gqlIssueOrPullRequestFragment = `
78
118
  closed
79
119
  closedAt
@@ -2866,6 +2906,55 @@ export class GitHubApi {
2866
2906
  return version ?? undefined;
2867
2907
  }
2868
2908
 
2909
+ // Runs `execute`, retrying transient gateway/network failures with backoff. `retryable` gates
2910
+ // retries to idempotent reads; `signal` lets an in-flight cancellation abort the backoff wait.
2911
+ private async requestWithRetries<T>(
2912
+ execute: () => Promise<T>,
2913
+ retryable: boolean,
2914
+ signal: AbortSignal | undefined,
2915
+ scope: ScopedLogger | undefined,
2916
+ ): Promise<T> {
2917
+ let attempt = 0;
2918
+ while (true) {
2919
+ try {
2920
+ return await execute();
2921
+ } catch (ex) {
2922
+ if (!retryable || attempt >= maxRequestRetries || signal?.aborted || !isRetryableTransientError(ex)) {
2923
+ throw ex;
2924
+ }
2925
+
2926
+ attempt++;
2927
+ const delay = getRequestRetryDelay(attempt);
2928
+ scope?.warn(
2929
+ `Transient request failure (status ${ex.status}); retrying ${attempt}/${maxRequestRetries} in ${delay}ms`,
2930
+ );
2931
+ await this.delayWithAbort(delay, signal);
2932
+ // Bail rather than retry if we were cancelled while waiting
2933
+ if (signal?.aborted) throw new CancellationError();
2934
+ }
2935
+ }
2936
+ }
2937
+
2938
+ private delayWithAbort(ms: number, signal: AbortSignal | undefined): Promise<void> {
2939
+ return new Promise<void>(resolve => {
2940
+ if (signal?.aborted) {
2941
+ resolve();
2942
+ return;
2943
+ }
2944
+
2945
+ let timer: ReturnType<typeof setTimeout>;
2946
+ const onAbort = () => {
2947
+ clearTimeout(timer);
2948
+ resolve();
2949
+ };
2950
+ timer = setTimeout(() => {
2951
+ signal?.removeEventListener('abort', onAbort);
2952
+ resolve();
2953
+ }, ms);
2954
+ signal?.addEventListener('abort', onAbort, { once: true });
2955
+ });
2956
+ }
2957
+
2869
2958
  // Inflight dedupe: identical concurrent GraphQL requests share a single promise
2870
2959
  // to cut redundant traffic during graph/details enrichment bursts.
2871
2960
  private readonly _pendingGraphQL = new Map<string, Promise<unknown>>();
@@ -2912,8 +3001,17 @@ export class GitHubApi {
2912
3001
  };
2913
3002
  }
2914
3003
 
2915
- return await this.config.wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () =>
2916
- this.getDefaults(accessToken, graphql)(query, variables),
3004
+ // Retry transient gateway/network failures, but only confirmed read queries — never
3005
+ // mutations (re-running one isn't idempotent)
3006
+ const retryable = graphqlReadQueryRegex.test(query);
3007
+ return await this.requestWithRetries(
3008
+ () =>
3009
+ this.config.wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () =>
3010
+ this.getDefaults(accessToken, graphql)(query, variables),
3011
+ ),
3012
+ retryable,
3013
+ aborter?.signal,
3014
+ scope,
2917
3015
  );
2918
3016
  } catch (ex) {
2919
3017
  if (ex instanceof GraphqlResponseError) {
@@ -2955,12 +3053,15 @@ export class GitHubApi {
2955
3053
  const promise = run();
2956
3054
  this._pendingGraphQL.set(dedupeKey, promise);
2957
3055
  // Clear from the inflight map once the promise settles — successes and failures both
2958
- // clear, so failed requests don't poison subsequent retries.
2959
- void promise.finally(() => {
3056
+ // clear, so failed requests don't poison subsequent retries. Use `then(cleanup, cleanup)`
3057
+ // rather than `finally` so a rejected request doesn't spawn an orphaned (unhandled) promise;
3058
+ // the real caller still receives the rejection via the returned `promise`.
3059
+ const cleanup = () => {
2960
3060
  if (this._pendingGraphQL.get(dedupeKey) === promise) {
2961
3061
  this._pendingGraphQL.delete(dedupeKey);
2962
3062
  }
2963
- });
3063
+ };
3064
+ promise.then(cleanup, cleanup);
2964
3065
  return promise;
2965
3066
  }
2966
3067
 
@@ -2974,16 +3075,27 @@ export class GitHubApi {
2974
3075
  ): Promise<Endpoints[R]['response']> {
2975
3076
  const { accessToken } = token;
2976
3077
  try {
3078
+ let signal: AbortSignal | undefined;
2977
3079
  if (cancellation != null) {
2978
3080
  if (cancellation.isCancellationRequested) throw new CancellationError();
2979
3081
 
2980
3082
  const aborter = new AbortController();
2981
3083
  cancellation.onCancellationRequested(() => aborter.abort());
2982
- options = { ...options, request: { ...options?.request, signal: aborter.signal } };
3084
+ signal = aborter.signal;
3085
+ options = { ...options, request: { ...options?.request, signal: signal } };
2983
3086
  }
2984
3087
 
2985
- return (await this.config.wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () =>
2986
- this.getDefaults(accessToken, request)(route as string, options),
3088
+ // Retry transient gateway/network failures on idempotent reads only
3089
+ const method = route.split(' ', 1)[0].toUpperCase();
3090
+ const retryable = method === 'GET' || method === 'HEAD';
3091
+ return (await this.requestWithRetries(
3092
+ () =>
3093
+ this.config.wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () =>
3094
+ this.getDefaults(accessToken, request)(route as string, options),
3095
+ ),
3096
+ retryable,
3097
+ signal,
3098
+ scope,
2987
3099
  )) as Endpoints[R]['response'];
2988
3100
  } catch (ex) {
2989
3101
  if (ex instanceof RequestError || ex.name === 'AbortError') {
@@ -17,7 +17,7 @@ import { toTokenInfo } from '../../api/tokenUtils.js';
17
17
  import type { GitHubGitProviderInternal } from '../githubProvider.js';
18
18
 
19
19
  // Since negative lookbehind isn't supported in all browsers, this leaves out the negative lookbehind condition `(?<!\.lock)` to ensure the branch name doesn't end with `.lock`
20
- // eslint-disable-next-line no-control-regex
20
+ // oxlint-disable-next-line no-control-regex
21
21
  const validBranchOrTagRegex = /^[^/](?!.*\/\.)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[^\x00-\x1F\x7F ~^:?*[\\]+[^./]$/;
22
22
 
23
23
  export class RefsGitSubProvider implements GitRefsSubProvider {
package/src/utils/date.ts CHANGED
@@ -80,6 +80,8 @@ export function createFromDateDelta(
80
80
 
81
81
  export function fromNow(date: Date | number, short?: boolean): string {
82
82
  const elapsed = (typeof date === 'number' ? date : date.getTime()) - Date.now();
83
+ // Guard against invalid dates (e.g. `new Date(<unparseable>)`), since a non-finite value would throw in `Intl.RelativeTimeFormat.format`
84
+ if (!Number.isFinite(elapsed)) return '';
83
85
 
84
86
  for (const [unit, threshold, divisor, shortUnit] of relativeUnitThresholds) {
85
87
  const elapsedABS = Math.abs(elapsed);
@@ -134,6 +136,9 @@ export function formatDate(
134
136
  ): string {
135
137
  format = format ?? undefined;
136
138
 
139
+ // Guard against invalid dates (e.g. `new Date(<unparseable>)`), since they would throw in `Intl.DateTimeFormat.format`
140
+ if (Number.isNaN(typeof date === 'number' ? date : date.getTime())) return '';
141
+
137
142
  const key = `${locale ?? ''}:${format}`;
138
143
 
139
144
  let formatter = dateTimeFormatCache.get(key);
@@ -276,7 +276,7 @@ export function debounce<F extends (...args: any[]) => ReturnType<F>>(
276
276
  if (aggregator != null && lastArgs != null) {
277
277
  lastArgs = aggregator(lastArgs, args);
278
278
  } else {
279
- // eslint-disable-next-line @typescript-eslint/no-this-alias
279
+ // oxlint-disable-next-line @typescript-eslint/no-this-alias
280
280
  lastThis = this;
281
281
  lastArgs = args;
282
282
  }
@@ -48,6 +48,7 @@ class ChunkByStringLengthIterator implements IterableIterator<string[]> {
48
48
  constructor(
49
49
  private readonly source: string[],
50
50
  private readonly maxLength: number,
51
+ private readonly overheadPerItem: number = 0,
51
52
  ) {}
52
53
 
53
54
  next(): IteratorResult<string[]> {
@@ -61,7 +62,7 @@ class ChunkByStringLengthIterator implements IterableIterator<string[]> {
61
62
 
62
63
  while (this.index < this.source.length) {
63
64
  const item = this.source[this.index];
64
- const length = chunkLength + item.length;
65
+ const length = chunkLength + item.length + this.overheadPerItem;
65
66
 
66
67
  if (length > this.maxLength && chunk.length > 0) {
67
68
  break;
@@ -80,8 +81,18 @@ class ChunkByStringLengthIterator implements IterableIterator<string[]> {
80
81
  }
81
82
  }
82
83
 
83
- export function chunkByStringLength(source: string[], maxLength: number): Iterable<string[]> {
84
- return new ChunkByStringLengthIterator(source, maxLength);
84
+ /**
85
+ * Chunks strings so each chunk's total serialized length stays within `maxLength`.
86
+ * `overheadPerItem` adds a fixed per-item cost to the running total — use it when the strings will
87
+ * be joined with a separator or otherwise expanded per item (e.g. CLI argv: one separator + quoting
88
+ * per argument), so the assembled result can't exceed the limit even with many short items.
89
+ */
90
+ export function chunkByStringLength(
91
+ source: string[],
92
+ maxLength: number,
93
+ overheadPerItem: number = 0,
94
+ ): Iterable<string[]> {
95
+ return new ChunkByStringLengthIterator(source, maxLength, overheadPerItem);
85
96
  }
86
97
 
87
98
  /**
@@ -1,11 +1,62 @@
1
- export function areEqual(a: any, b: any): boolean {
1
+ /**
2
+ * Structural deep-equality. Walks both values and short-circuits on the first difference without
3
+ * serializing to a string (unlike a `JSON.stringify` compare): the `a === b` fast path skips
4
+ * unchanged shared subtrees, and unequal values bail at the first mismatched field. Handles
5
+ * primitives, `null`/`undefined`, `Date` (by timestamp), arrays, and plain objects.
6
+ *
7
+ * Differs from the prior `JSON.stringify` compare in three ways — each intended or matching the old
8
+ * behavior:
9
+ * - Key-order-insensitive (the old compare was order-sensitive). Observable at one caller
10
+ * (`configuration.update`, when a config value equals its schema default but with different key
11
+ * order), where it harmlessly treats the value as the default rather than persisting a redundant
12
+ * override.
13
+ * - A present-but-`undefined` key is distinct from an absent key (the old compare dropped
14
+ * `undefined` keys).
15
+ * - `Map`/`Set`/`RegExp` and other exotic objects are NOT deep-compared — they reduce to their
16
+ * own-enumerable keys (so two are effectively always-equal), same as the old stringify behavior.
17
+ * No caller compares those.
18
+ *
19
+ * Assumes acyclic input — it recurses without a cycle guard (a self-referential value would
20
+ * overflow the stack). Every caller compares JSON-origin or same-shape data.
21
+ */
22
+ export function areEqual(a: unknown, b: unknown): boolean {
2
23
  if (a === b) return true;
3
24
  if (a == null || b == null) return false;
4
25
 
5
- const aType = typeof a;
6
- if (aType === typeof b && (aType === 'string' || aType === 'number' || aType === 'boolean')) return false;
26
+ const type = typeof a;
27
+ if (type !== typeof b || type !== 'object') return false;
28
+
29
+ // Both are non-null objects from here.
30
+ if (a instanceof Date) return b instanceof Date && a.getTime() === b.getTime();
31
+ if (b instanceof Date) return false;
32
+
33
+ const aIsArray = Array.isArray(a);
34
+ if (aIsArray !== Array.isArray(b)) return false;
35
+
36
+ if (aIsArray) {
37
+ const arrA = a as unknown[];
38
+ const arrB = b as unknown[];
39
+ if (arrA.length !== arrB.length) return false;
7
40
 
8
- return JSON.stringify(a) === JSON.stringify(b);
41
+ for (let i = 0; i < arrA.length; i++) {
42
+ if (!areEqual(arrA[i], arrB[i])) return false;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ const objA = a as Record<string, unknown>;
48
+ const objB = b as Record<string, unknown>;
49
+ const keysA = Object.keys(objA);
50
+ if (keysA.length !== Object.keys(objB).length) return false;
51
+
52
+ // Equal key COUNT alone doesn't prove equal key SETS — without the `hasOwn` check,
53
+ // `{ a: 1, b: undefined }` and `{ a: 1, c: undefined }` would falsely compare equal (each
54
+ // missing key reads as `undefined`). The count check plus "every A-key is an own-key of B"
55
+ // together guarantee identical key sets.
56
+ for (const key of keysA) {
57
+ if (!Object.hasOwn(objB, key) || !areEqual(objA[key], objB[key])) return false;
58
+ }
59
+ return true;
9
60
  }
10
61
 
11
62
  type Primitive = string | number | boolean;
@@ -26,24 +26,24 @@ export class PageableResult<T> {
26
26
  }
27
27
 
28
28
  async *values(): AsyncIterable<NonNullable<T>> {
29
- if (this.cached != null) {
30
- for (const value of this.cached.values) {
31
- yield value;
32
- }
29
+ let page = this.cached;
30
+ if (page == null) {
31
+ // No seed or cached results yet, so perform the initial fetch
32
+ page = await this.fetch(undefined);
33
+ this.cached = page;
33
34
  }
34
35
 
35
- let results = this.cached;
36
- while (results == null || results.paging?.more) {
37
- results = await this.fetch(results?.paging);
36
+ const cached = page; // memoized accumulator (the first page)
37
+ for (const value of cached.values) {
38
+ yield value;
39
+ }
38
40
 
39
- if (this.cached == null) {
40
- this.cached = results;
41
- } else {
42
- this.cached.values.push(...results.values);
43
- this.cached.paging = results.paging;
44
- }
41
+ while (page.paging?.more) {
42
+ page = await this.fetch(page.paging);
43
+ cached.values.push(...page.values);
44
+ cached.paging = page.paging;
45
45
 
46
- for (const value of results.values) {
46
+ for (const value of page.values) {
47
47
  yield value;
48
48
  }
49
49
  }
@@ -172,14 +172,18 @@ export class PromiseCache<K, V> {
172
172
  this.controllers.set(key, cacheable);
173
173
 
174
174
  const promise = factory(cacheable, abortAgg.signal);
175
- void promise.finally(() => {
176
- if (cacheable.invalidated) {
177
- this.cache.delete(key);
178
- }
179
- abortAgg.dispose();
180
- this.aborts.delete(key);
181
- this.controllers.delete(key);
182
- });
175
+ void promise
176
+ .finally(() => {
177
+ if (cacheable.invalidated) {
178
+ this.cache.delete(key);
179
+ }
180
+ abortAgg.dispose();
181
+ this.aborts.delete(key);
182
+ this.controllers.delete(key);
183
+ })
184
+ // Swallow the cleanup chain's rejection so it doesn't surface as an unhandled rejection
185
+ // separate from the caller-facing promise's own handling (e.g. on cancellation).
186
+ .catch(() => {});
183
187
 
184
188
  if (options?.onError != null) {
185
189
  // Wrap the promise to handle errors with the onError callback
@@ -393,14 +397,18 @@ export class PromiseMap<K, V> {
393
397
 
394
398
  // Automatically remove failed promises from the cache
395
399
  promise.catch(() => this.cache.delete(key));
396
- void promise.finally(() => {
397
- if (cacheable.invalidated) {
398
- this.cache.delete(key);
399
- }
400
- aborts.dispose();
401
- this.aborts.delete(key);
402
- this.controllers.delete(key);
403
- });
400
+ void promise
401
+ .finally(() => {
402
+ if (cacheable.invalidated) {
403
+ this.cache.delete(key);
404
+ }
405
+ aborts.dispose();
406
+ this.aborts.delete(key);
407
+ this.controllers.delete(key);
408
+ })
409
+ // Swallow the cleanup chain's rejection so it doesn't surface as an unhandled rejection
410
+ // separate from the caller-facing promise's own handling (e.g. on cancellation).
411
+ .catch(() => {});
404
412
 
405
413
  this.cache.set(key, promise);
406
414
 
@@ -769,7 +769,7 @@ export function pluralize(
769
769
  }
770
770
 
771
771
  // Removes \ / : * ? " < > | and C0 and C1 control codes
772
- // eslint-disable-next-line no-control-regex
772
+ // oxlint-disable-next-line no-control-regex
773
773
  const illegalCharsForFSRegex = /[\\/:*?"<>|\x00-\x1f\x80-\x9f]/g;
774
774
 
775
775
  export function sanitizeForFileSystem(s: string, replacement: string = '_'): string {