@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.
- package/CHANGELOG.md +8 -1
- package/dist/git/cache.d.ts +12 -6
- package/dist/git/cache.d.ts.map +1 -1
- package/dist/git/cache.js +40 -39
- package/dist/git/cache.js.map +1 -1
- package/dist/git/context.d.ts +19 -0
- package/dist/git/context.d.ts.map +1 -1
- package/dist/git/context.js.map +1 -1
- package/dist/git/features.d.ts +1 -1
- package/dist/git/features.d.ts.map +1 -1
- package/dist/git/features.js +1 -0
- package/dist/git/features.js.map +1 -1
- package/dist/git/models/graph.d.ts +96 -0
- package/dist/git/models/graph.d.ts.map +1 -1
- package/dist/git/models/graph.js +24 -1
- package/dist/git/models/graph.js.map +1 -1
- package/dist/git/models/remoteProvider.d.ts +1 -0
- package/dist/git/models/remoteProvider.d.ts.map +1 -1
- package/dist/git/models/remoteProvider.js.map +1 -1
- package/dist/git/models/repository.d.ts +21 -12
- package/dist/git/models/repository.d.ts.map +1 -1
- package/dist/git/models/repository.js +70 -84
- package/dist/git/models/repository.js.map +1 -1
- package/dist/git/models/statusFile.d.ts +9 -0
- package/dist/git/models/statusFile.d.ts.map +1 -1
- package/dist/git/models/statusFile.js +11 -0
- package/dist/git/models/statusFile.js.map +1 -1
- package/dist/git/providers/commits.d.ts +4 -0
- package/dist/git/providers/commits.d.ts.map +1 -1
- package/dist/git/providers/config.d.ts +1 -3
- package/dist/git/providers/config.d.ts.map +1 -1
- package/dist/git/providers/operations.d.ts +39 -13
- package/dist/git/providers/operations.d.ts.map +1 -1
- package/dist/git/remotes/custom.d.ts +2 -0
- package/dist/git/remotes/custom.d.ts.map +1 -1
- package/dist/git/remotes/custom.js +20 -0
- package/dist/git/remotes/custom.js.map +1 -1
- package/dist/git/utils/contributor.utils.d.ts +3 -0
- package/dist/git/utils/contributor.utils.d.ts.map +1 -1
- package/dist/git/utils/contributor.utils.js +22 -0
- package/dist/git/utils/contributor.utils.js.map +1 -1
- package/dist/git/utils/reachability.utils.d.ts +37 -0
- package/dist/git/utils/reachability.utils.d.ts.map +1 -0
- package/dist/git/utils/reachability.utils.js +96 -0
- package/dist/git/utils/reachability.utils.js.map +1 -0
- package/dist/git/watching/watchService.d.ts +4 -2
- package/dist/git/watching/watchService.d.ts.map +1 -1
- package/dist/git/watching/watchService.js +8 -3
- package/dist/git/watching/watchService.js.map +1 -1
- package/dist/git/watching/watchSession.d.ts +14 -12
- package/dist/git/watching/watchSession.d.ts.map +1 -1
- package/dist/git/watching/watchSession.js +56 -55
- package/dist/git/watching/watchSession.js.map +1 -1
- package/dist/git-cli/providers/branches.d.ts.map +1 -1
- package/dist/git-cli/providers/branches.js +1 -0
- package/dist/git-cli/providers/branches.js.map +1 -1
- package/dist/git-cli/providers/commits.d.ts +1 -0
- package/dist/git-cli/providers/commits.d.ts.map +1 -1
- package/dist/git-cli/providers/commits.js +12 -0
- package/dist/git-cli/providers/commits.js.map +1 -1
- package/dist/git-cli/providers/config.d.ts +8 -4
- package/dist/git-cli/providers/config.d.ts.map +1 -1
- package/dist/git-cli/providers/config.js +62 -39
- package/dist/git-cli/providers/config.js.map +1 -1
- package/dist/git-cli/providers/diff.d.ts.map +1 -1
- package/dist/git-cli/providers/diff.js +1 -1
- package/dist/git-cli/providers/diff.js.map +1 -1
- package/dist/git-cli/providers/graph.d.ts.map +1 -1
- package/dist/git-cli/providers/graph.js +132 -14
- package/dist/git-cli/providers/graph.js.map +1 -1
- package/dist/git-cli/providers/operations.d.ts +6 -5
- package/dist/git-cli/providers/operations.d.ts.map +1 -1
- package/dist/git-cli/providers/operations.js +97 -95
- package/dist/git-cli/providers/operations.js.map +1 -1
- package/dist/git-cli/providers/stash.d.ts.map +1 -1
- package/dist/git-cli/providers/stash.js +20 -2
- package/dist/git-cli/providers/stash.js.map +1 -1
- package/dist/git-cli/providers/status.d.ts.map +1 -1
- package/dist/git-cli/providers/status.js +5 -2
- package/dist/git-cli/providers/status.js.map +1 -1
- package/dist/ipc/discovery.d.ts +22 -0
- package/dist/ipc/discovery.d.ts.map +1 -1
- package/dist/ipc/discovery.js +111 -2
- package/dist/ipc/discovery.js.map +1 -1
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts +73 -20
- package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +1 -1
- package/dist/plus/agents/providers/claudeCodeProvider.js +394 -88
- package/dist/plus/agents/providers/claudeCodeProvider.js.map +1 -1
- package/dist/plus/agents/types.d.ts +62 -11
- package/dist/plus/agents/types.d.ts.map +1 -1
- package/dist/plus/ai/models/model.d.ts +1 -1
- package/dist/plus/ai/models/model.d.ts.map +1 -1
- package/dist/plus/ai/models/model.js.map +1 -1
- package/dist/plus/ai/models/provider.d.ts +3 -0
- package/dist/plus/ai/models/provider.d.ts.map +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.d.ts +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.d.ts.map +1 -1
- package/dist/plus/ai/providers/gitkrakenProvider.js +4 -1
- package/dist/plus/ai/providers/gitkrakenProvider.js.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts +4 -3
- package/dist/plus/ai/providers/openAICompatibleProviderBase.d.ts.map +1 -1
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js +9 -6
- package/dist/plus/ai/providers/openAICompatibleProviderBase.js.map +1 -1
- package/dist/plus/ai/utils/ai.utils.d.ts.map +1 -1
- package/dist/plus/ai/utils/ai.utils.js +2 -0
- package/dist/plus/ai/utils/ai.utils.js.map +1 -1
- package/dist/plus/git-github/api/github.d.ts +2 -0
- package/dist/plus/git-github/api/github.d.ts.map +1 -1
- package/dist/plus/git-github/api/github.js +93 -6
- package/dist/plus/git-github/api/github.js.map +1 -1
- package/dist/plus/git-github/providers/github/refs.js +1 -1
- package/dist/utils/date.d.ts.map +1 -1
- package/dist/utils/date.js +6 -0
- package/dist/utils/date.js.map +1 -1
- package/dist/utils/debounce.js +1 -1
- package/dist/utils/iterable.d.ts +7 -1
- package/dist/utils/iterable.d.ts.map +1 -1
- package/dist/utils/iterable.js +12 -4
- package/dist/utils/iterable.js.map +1 -1
- package/dist/utils/object.d.ts +22 -1
- package/dist/utils/object.d.ts.map +1 -1
- package/dist/utils/object.js +56 -3
- package/dist/utils/object.js.map +1 -1
- package/dist/utils/paging.js +14 -15
- package/dist/utils/paging.js.map +1 -1
- package/dist/utils/promiseCache.d.ts.map +1 -1
- package/dist/utils/promiseCache.js +12 -4
- package/dist/utils/promiseCache.js.map +1 -1
- package/dist/utils/string.js +1 -1
- package/package.json +1 -1
- package/src/git/cache.ts +58 -70
- package/src/git/context.ts +21 -0
- package/src/git/features.ts +2 -0
- package/src/git/models/graph.ts +97 -0
- package/src/git/models/remoteProvider.ts +1 -0
- package/src/git/models/repository.ts +84 -93
- package/src/git/models/statusFile.ts +12 -0
- package/src/git/providers/commits.ts +4 -0
- package/src/git/providers/config.ts +1 -5
- package/src/git/providers/operations.ts +42 -22
- package/src/git/remotes/custom.ts +23 -0
- package/src/git/utils/contributor.utils.ts +24 -0
- package/src/git/utils/reachability.utils.ts +109 -0
- package/src/git/watching/watchService.ts +9 -4
- package/src/git/watching/watchSession.ts +61 -55
- package/src/git-cli/providers/branches.ts +1 -0
- package/src/git-cli/providers/commits.ts +24 -0
- package/src/git-cli/providers/config.ts +63 -49
- package/src/git-cli/providers/diff.ts +2 -1
- package/src/git-cli/providers/graph.ts +145 -14
- package/src/git-cli/providers/operations.ts +118 -130
- package/src/git-cli/providers/stash.ts +20 -2
- package/src/git-cli/providers/status.ts +11 -3
- package/src/ipc/discovery.ts +132 -2
- package/src/plus/agents/providers/claudeCodeProvider.ts +430 -90
- package/src/plus/agents/types.ts +59 -11
- package/src/plus/ai/models/model.ts +1 -0
- package/src/plus/ai/models/provider.ts +7 -1
- package/src/plus/ai/providers/gitkrakenProvider.ts +4 -0
- package/src/plus/ai/providers/openAICompatibleProviderBase.ts +21 -4
- package/src/plus/ai/utils/ai.utils.ts +2 -0
- package/src/plus/git-github/api/github.ts +120 -8
- package/src/plus/git-github/providers/github/refs.ts +1 -1
- package/src/utils/date.ts +5 -0
- package/src/utils/debounce.ts +1 -1
- package/src/utils/iterable.ts +14 -3
- package/src/utils/object.ts +55 -4
- package/src/utils/paging.ts +14 -14
- package/src/utils/promiseCache.ts +24 -16
- package/src/utils/string.ts +1 -1
package/src/plus/agents/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
143
|
-
* NotebookEdit). Populated on PreToolUse
|
|
144
|
-
*
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
|
|
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: {
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
2916
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3084
|
+
signal = aborter.signal;
|
|
3085
|
+
options = { ...options, request: { ...options?.request, signal: signal } };
|
|
2983
3086
|
}
|
|
2984
3087
|
|
|
2985
|
-
|
|
2986
|
-
|
|
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
|
-
//
|
|
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);
|
package/src/utils/debounce.ts
CHANGED
|
@@ -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
|
-
//
|
|
279
|
+
// oxlint-disable-next-line @typescript-eslint/no-this-alias
|
|
280
280
|
lastThis = this;
|
|
281
281
|
lastArgs = args;
|
|
282
282
|
}
|
package/src/utils/iterable.ts
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
/**
|
package/src/utils/object.ts
CHANGED
|
@@ -1,11 +1,62 @@
|
|
|
1
|
-
|
|
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
|
|
6
|
-
if (
|
|
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
|
-
|
|
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;
|
package/src/utils/paging.ts
CHANGED
|
@@ -26,24 +26,24 @@ export class PageableResult<T> {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
async *values(): AsyncIterable<NonNullable<T>> {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const cached = page; // memoized accumulator (the first page)
|
|
37
|
+
for (const value of cached.values) {
|
|
38
|
+
yield value;
|
|
39
|
+
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
package/src/utils/string.ts
CHANGED
|
@@ -769,7 +769,7 @@ export function pluralize(
|
|
|
769
769
|
}
|
|
770
770
|
|
|
771
771
|
// Removes \ / : * ? " < > | and C0 and C1 control codes
|
|
772
|
-
//
|
|
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 {
|