@pierre/storage 0.9.2 → 1.0.0

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/src/index.ts CHANGED
@@ -3,86 +3,91 @@
3
3
  *
4
4
  * A TypeScript SDK for interacting with Pierre's git storage system
5
5
  */
6
-
7
- import { importPKCS8, SignJWT } from 'jose';
6
+ import { SignJWT, importPKCS8 } from 'jose';
8
7
  import snakecaseKeys from 'snakecase-keys';
9
- import { createCommitBuilder, FetchCommitTransport, resolveCommitTtlSeconds } from './commit';
8
+
9
+ import {
10
+ FetchCommitTransport,
11
+ createCommitBuilder,
12
+ resolveCommitTtlSeconds,
13
+ } from './commit';
10
14
  import { FetchDiffCommitTransport, sendCommitFromDiff } from './diff-commit';
11
15
  import { RefUpdateError } from './errors';
12
16
  import { ApiError, ApiFetcher } from './fetch';
13
17
  import type { RestoreCommitAckRaw } from './schemas';
14
18
  import {
15
- branchDiffResponseSchema,
16
- commitDiffResponseSchema,
17
- createBranchResponseSchema,
18
- errorEnvelopeSchema,
19
- grepResponseSchema,
20
- listBranchesResponseSchema,
21
- listCommitsResponseSchema,
22
- listFilesResponseSchema,
23
- listReposResponseSchema,
24
- noteReadResponseSchema,
25
- noteWriteResponseSchema,
26
- restoreCommitAckSchema,
27
- restoreCommitResponseSchema,
19
+ branchDiffResponseSchema,
20
+ commitDiffResponseSchema,
21
+ createBranchResponseSchema,
22
+ errorEnvelopeSchema,
23
+ grepResponseSchema,
24
+ listBranchesResponseSchema,
25
+ listCommitsResponseSchema,
26
+ listFilesResponseSchema,
27
+ listReposResponseSchema,
28
+ noteReadResponseSchema,
29
+ noteWriteResponseSchema,
30
+ restoreCommitAckSchema,
31
+ restoreCommitResponseSchema,
28
32
  } from './schemas';
29
33
  import type {
30
- AppendNoteOptions,
31
- BranchInfo,
32
- CommitBuilder,
33
- CommitInfo,
34
- CommitResult,
35
- CreateBranchOptions,
36
- CreateBranchResponse,
37
- CreateBranchResult,
38
- CreateCommitFromDiffOptions,
39
- CreateCommitOptions,
40
- CreateNoteOptions,
41
- CreateRepoOptions,
42
- DeleteNoteOptions,
43
- DeleteRepoOptions,
44
- DeleteRepoResult,
45
- DiffFileState,
46
- FileDiff,
47
- FilteredFile,
48
- FindOneOptions,
49
- GetBranchDiffOptions,
50
- GetBranchDiffResponse,
51
- GetBranchDiffResult,
52
- GetCommitDiffOptions,
53
- GetCommitDiffResponse,
54
- GetCommitDiffResult,
55
- GetFileOptions,
56
- GetNoteOptions,
57
- GetNoteResult,
58
- GetRemoteURLOptions,
59
- GitStorageOptions,
60
- GrepFileMatch,
61
- GrepLine,
62
- GrepOptions,
63
- GrepResult,
64
- ListBranchesOptions,
65
- ListBranchesResponse,
66
- ListBranchesResult,
67
- ListCommitsOptions,
68
- ListCommitsResponse,
69
- ListCommitsResult,
70
- ListFilesOptions,
71
- ListFilesResult,
72
- ListReposOptions,
73
- ListReposResponse,
74
- ListReposResult,
75
- NoteWriteResult,
76
- PullUpstreamOptions,
77
- RawBranchInfo,
78
- RawCommitInfo,
79
- RawFileDiff,
80
- RawFilteredFile,
81
- RefUpdate,
82
- Repo,
83
- RestoreCommitOptions,
84
- RestoreCommitResult,
85
- ValidAPIVersion,
34
+ AppendNoteOptions,
35
+ ArchiveOptions,
36
+ BranchInfo,
37
+ CommitBuilder,
38
+ CommitInfo,
39
+ CommitResult,
40
+ CreateBranchOptions,
41
+ CreateBranchResponse,
42
+ CreateBranchResult,
43
+ CreateCommitFromDiffOptions,
44
+ CreateCommitOptions,
45
+ CreateNoteOptions,
46
+ CreateRepoOptions,
47
+ DeleteNoteOptions,
48
+ DeleteRepoOptions,
49
+ DeleteRepoResult,
50
+ DiffFileState,
51
+ FileDiff,
52
+ FilteredFile,
53
+ FindOneOptions,
54
+ GetBranchDiffOptions,
55
+ GetBranchDiffResponse,
56
+ GetBranchDiffResult,
57
+ GetCommitDiffOptions,
58
+ GetCommitDiffResponse,
59
+ GetCommitDiffResult,
60
+ GetFileOptions,
61
+ GetNoteOptions,
62
+ GetNoteResult,
63
+ GetRemoteURLOptions,
64
+ GitStorageOptions,
65
+ GrepFileMatch,
66
+ GrepLine,
67
+ GrepOptions,
68
+ GrepResult,
69
+ ListBranchesOptions,
70
+ ListBranchesResponse,
71
+ ListBranchesResult,
72
+ ListCommitsOptions,
73
+ ListCommitsResponse,
74
+ ListCommitsResult,
75
+ ListFilesOptions,
76
+ ListFilesResult,
77
+ ListReposOptions,
78
+ ListReposResponse,
79
+ ListReposResult,
80
+ NoteWriteResult,
81
+ PullUpstreamOptions,
82
+ RawBranchInfo,
83
+ RawCommitInfo,
84
+ RawFileDiff,
85
+ RawFilteredFile,
86
+ RefUpdate,
87
+ Repo,
88
+ RestoreCommitOptions,
89
+ RestoreCommitResult,
90
+ ValidAPIVersion,
86
91
  } from './types';
87
92
 
88
93
  /**
@@ -95,7 +100,11 @@ export { ApiError } from './fetch';
95
100
  export * from './types';
96
101
 
97
102
  // Export webhook validation utilities
98
- export { parseSignatureHeader, validateWebhook, validateWebhookSignature } from './webhook';
103
+ export {
104
+ parseSignatureHeader,
105
+ validateWebhook,
106
+ validateWebhookSignature,
107
+ } from './webhook';
99
108
 
100
109
  /**
101
110
  * Git Storage API
@@ -111,1277 +120,1419 @@ const API_VERSION: ValidAPIVersion = 1;
111
120
  const apiInstanceMap = new Map<string, ApiFetcher>();
112
121
  const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60; // 1 hour
113
122
  const RESTORE_COMMIT_ALLOWED_STATUS = [
114
- 400, // Bad Request - validation errors
115
- 401, // Unauthorized - missing/invalid auth header
116
- 403, // Forbidden - missing git:write scope
117
- 404, // Not Found - repo lookup failures
118
- 408, // Request Timeout - client cancelled
119
- 409, // Conflict - concurrent ref updates
120
- 412, // Precondition Failed - optimistic concurrency
121
- 422, // Unprocessable Entity - metadata issues
122
- 429, // Too Many Requests - upstream throttling
123
- 499, // Client Closed Request - storage cancellation
124
- 500, // Internal Server Error - generic failure
125
- 502, // Bad Gateway - storage/gateway bridge issues
126
- 503, // Service Unavailable - storage selection failures
127
- 504, // Gateway Timeout - long-running storage operations
123
+ 400, // Bad Request - validation errors
124
+ 401, // Unauthorized - missing/invalid auth header
125
+ 403, // Forbidden - missing git:write scope
126
+ 404, // Not Found - repo lookup failures
127
+ 408, // Request Timeout - client cancelled
128
+ 409, // Conflict - concurrent ref updates
129
+ 412, // Precondition Failed - optimistic concurrency
130
+ 422, // Unprocessable Entity - metadata issues
131
+ 429, // Too Many Requests - upstream throttling
132
+ 499, // Client Closed Request - storage cancellation
133
+ 500, // Internal Server Error - generic failure
134
+ 502, // Bad Gateway - storage/gateway bridge issues
135
+ 503, // Service Unavailable - storage selection failures
136
+ 504, // Gateway Timeout - long-running storage operations
128
137
  ] as const;
129
138
 
130
139
  const NOTE_WRITE_ALLOWED_STATUS = [
131
- 400, // Bad Request - validation errors
132
- 401, // Unauthorized - missing/invalid auth header
133
- 403, // Forbidden - missing git:write scope
134
- 404, // Not Found - repo or note lookup failures
135
- 408, // Request Timeout - client cancelled
136
- 409, // Conflict - concurrent ref updates
137
- 412, // Precondition Failed - optimistic concurrency
138
- 422, // Unprocessable Entity - metadata issues
139
- 429, // Too Many Requests - upstream throttling
140
- 499, // Client Closed Request - storage cancellation
141
- 500, // Internal Server Error - generic failure
142
- 502, // Bad Gateway - storage/gateway bridge issues
143
- 503, // Service Unavailable - storage selection failures
144
- 504, // Gateway Timeout - long-running storage operations
140
+ 400, // Bad Request - validation errors
141
+ 401, // Unauthorized - missing/invalid auth header
142
+ 403, // Forbidden - missing git:write scope
143
+ 404, // Not Found - repo or note lookup failures
144
+ 408, // Request Timeout - client cancelled
145
+ 409, // Conflict - concurrent ref updates
146
+ 412, // Precondition Failed - optimistic concurrency
147
+ 422, // Unprocessable Entity - metadata issues
148
+ 429, // Too Many Requests - upstream throttling
149
+ 499, // Client Closed Request - storage cancellation
150
+ 500, // Internal Server Error - generic failure
151
+ 502, // Bad Gateway - storage/gateway bridge issues
152
+ 503, // Service Unavailable - storage selection failures
153
+ 504, // Gateway Timeout - long-running storage operations
145
154
  ] as const;
146
155
 
147
156
  function resolveInvocationTtlSeconds(
148
- options?: { ttl?: number },
149
- defaultValue: number = DEFAULT_TOKEN_TTL_SECONDS,
157
+ options?: { ttl?: number },
158
+ defaultValue: number = DEFAULT_TOKEN_TTL_SECONDS
150
159
  ): number {
151
- if (typeof options?.ttl === 'number' && options.ttl > 0) {
152
- return options.ttl;
153
- }
154
- return defaultValue;
160
+ if (typeof options?.ttl === 'number' && options.ttl > 0) {
161
+ return options.ttl;
162
+ }
163
+ return defaultValue;
155
164
  }
156
165
 
157
166
  type RestoreCommitAck = RestoreCommitAckRaw;
158
167
 
159
168
  function toRefUpdate(result: RestoreCommitAck['result']): RefUpdate {
160
- return {
161
- branch: result.branch,
162
- oldSha: result.old_sha,
163
- newSha: result.new_sha,
164
- };
169
+ return {
170
+ branch: result.branch,
171
+ oldSha: result.old_sha,
172
+ newSha: result.new_sha,
173
+ };
165
174
  }
166
175
 
167
176
  function buildRestoreCommitResult(ack: RestoreCommitAck): RestoreCommitResult {
168
- const refUpdate = toRefUpdate(ack.result);
169
- if (!ack.result.success) {
170
- throw new RefUpdateError(
171
- ack.result.message ?? `Restore commit failed with status ${ack.result.status}`,
172
- {
173
- status: ack.result.status,
174
- message: ack.result.message,
175
- refUpdate,
176
- },
177
- );
178
- }
179
- return {
180
- commitSha: ack.commit.commit_sha,
181
- treeSha: ack.commit.tree_sha,
182
- targetBranch: ack.commit.target_branch,
183
- packBytes: ack.commit.pack_bytes,
184
- refUpdate,
185
- };
177
+ const refUpdate = toRefUpdate(ack.result);
178
+ if (!ack.result.success) {
179
+ throw new RefUpdateError(
180
+ ack.result.message ??
181
+ `Restore commit failed with status ${ack.result.status}`,
182
+ {
183
+ status: ack.result.status,
184
+ message: ack.result.message,
185
+ refUpdate,
186
+ }
187
+ );
188
+ }
189
+ return {
190
+ commitSha: ack.commit.commit_sha,
191
+ treeSha: ack.commit.tree_sha,
192
+ targetBranch: ack.commit.target_branch,
193
+ packBytes: ack.commit.pack_bytes,
194
+ refUpdate,
195
+ };
186
196
  }
187
197
 
188
198
  interface RestoreCommitFailureInfo {
189
- status?: string;
190
- message?: string;
191
- refUpdate?: Partial<RefUpdate>;
199
+ status?: string;
200
+ message?: string;
201
+ refUpdate?: Partial<RefUpdate>;
192
202
  }
193
203
 
194
204
  function toPartialRefUpdate(
195
- branch?: unknown,
196
- oldSha?: unknown,
197
- newSha?: unknown,
205
+ branch?: unknown,
206
+ oldSha?: unknown,
207
+ newSha?: unknown
198
208
  ): Partial<RefUpdate> | undefined {
199
- const refUpdate: Partial<RefUpdate> = {};
200
- if (typeof branch === 'string' && branch.trim() !== '') {
201
- refUpdate.branch = branch;
202
- }
203
- if (typeof oldSha === 'string' && oldSha.trim() !== '') {
204
- refUpdate.oldSha = oldSha;
205
- }
206
- if (typeof newSha === 'string' && newSha.trim() !== '') {
207
- refUpdate.newSha = newSha;
208
- }
209
- return Object.keys(refUpdate).length > 0 ? refUpdate : undefined;
209
+ const refUpdate: Partial<RefUpdate> = {};
210
+ if (typeof branch === 'string' && branch.trim() !== '') {
211
+ refUpdate.branch = branch;
212
+ }
213
+ if (typeof oldSha === 'string' && oldSha.trim() !== '') {
214
+ refUpdate.oldSha = oldSha;
215
+ }
216
+ if (typeof newSha === 'string' && newSha.trim() !== '') {
217
+ refUpdate.newSha = newSha;
218
+ }
219
+ return Object.keys(refUpdate).length > 0 ? refUpdate : undefined;
210
220
  }
211
221
 
212
222
  function parseRestoreCommitPayload(
213
- payload: unknown,
223
+ payload: unknown
214
224
  ): { ack: RestoreCommitAck } | { failure: RestoreCommitFailureInfo } | null {
215
- const ack = restoreCommitAckSchema.safeParse(payload);
216
- if (ack.success) {
217
- return { ack: ack.data };
218
- }
219
-
220
- const failure = restoreCommitResponseSchema.safeParse(payload);
221
- if (failure.success) {
222
- const result = failure.data.result;
223
- return {
224
- failure: {
225
- status: result.status,
226
- message: result.message,
227
- refUpdate: toPartialRefUpdate(result.branch, result.old_sha, result.new_sha),
228
- },
229
- };
230
- }
231
-
232
- return null;
225
+ const ack = restoreCommitAckSchema.safeParse(payload);
226
+ if (ack.success) {
227
+ return { ack: ack.data };
228
+ }
229
+
230
+ const failure = restoreCommitResponseSchema.safeParse(payload);
231
+ if (failure.success) {
232
+ const result = failure.data.result;
233
+ return {
234
+ failure: {
235
+ status: result.status,
236
+ message: result.message,
237
+ refUpdate: toPartialRefUpdate(
238
+ result.branch,
239
+ result.old_sha,
240
+ result.new_sha
241
+ ),
242
+ },
243
+ };
244
+ }
245
+
246
+ return null;
233
247
  }
234
248
 
235
249
  function httpStatusToRestoreStatus(status: number): string {
236
- switch (status) {
237
- case 409:
238
- return 'conflict';
239
- case 412:
240
- return 'precondition_failed';
241
- default:
242
- return `${status}`;
243
- }
250
+ switch (status) {
251
+ case 409:
252
+ return 'conflict';
253
+ case 412:
254
+ return 'precondition_failed';
255
+ default:
256
+ return `${status}`;
257
+ }
244
258
  }
245
259
 
246
260
  function getApiInstance(baseUrl: string, version: ValidAPIVersion) {
247
- if (!apiInstanceMap.has(`${baseUrl}--${version}`)) {
248
- apiInstanceMap.set(`${baseUrl}--${version}`, new ApiFetcher(baseUrl, version));
249
- }
250
- return apiInstanceMap.get(`${baseUrl}--${version}`)!;
261
+ if (!apiInstanceMap.has(`${baseUrl}--${version}`)) {
262
+ apiInstanceMap.set(
263
+ `${baseUrl}--${version}`,
264
+ new ApiFetcher(baseUrl, version)
265
+ );
266
+ }
267
+ return apiInstanceMap.get(`${baseUrl}--${version}`)!;
251
268
  }
252
269
 
253
270
  function transformBranchInfo(raw: RawBranchInfo): BranchInfo {
254
- return {
255
- cursor: raw.cursor,
256
- name: raw.name,
257
- headSha: raw.head_sha,
258
- createdAt: raw.created_at,
259
- };
271
+ return {
272
+ cursor: raw.cursor,
273
+ name: raw.name,
274
+ headSha: raw.head_sha,
275
+ createdAt: raw.created_at,
276
+ };
260
277
  }
261
278
 
262
- function transformListBranchesResult(raw: ListBranchesResponse): ListBranchesResult {
263
- return {
264
- branches: raw.branches.map(transformBranchInfo),
265
- nextCursor: raw.next_cursor ?? undefined,
266
- hasMore: raw.has_more,
267
- };
279
+ function transformListBranchesResult(
280
+ raw: ListBranchesResponse
281
+ ): ListBranchesResult {
282
+ return {
283
+ branches: raw.branches.map(transformBranchInfo),
284
+ nextCursor: raw.next_cursor ?? undefined,
285
+ hasMore: raw.has_more,
286
+ };
268
287
  }
269
288
 
270
289
  function transformCommitInfo(raw: RawCommitInfo): CommitInfo {
271
- const parsedDate = new Date(raw.date);
272
- return {
273
- sha: raw.sha,
274
- message: raw.message,
275
- authorName: raw.author_name,
276
- authorEmail: raw.author_email,
277
- committerName: raw.committer_name,
278
- committerEmail: raw.committer_email,
279
- date: parsedDate,
280
- rawDate: raw.date,
281
- };
290
+ const parsedDate = new Date(raw.date);
291
+ return {
292
+ sha: raw.sha,
293
+ message: raw.message,
294
+ authorName: raw.author_name,
295
+ authorEmail: raw.author_email,
296
+ committerName: raw.committer_name,
297
+ committerEmail: raw.committer_email,
298
+ date: parsedDate,
299
+ rawDate: raw.date,
300
+ };
282
301
  }
283
302
 
284
- function transformListCommitsResult(raw: ListCommitsResponse): ListCommitsResult {
285
- return {
286
- commits: raw.commits.map(transformCommitInfo),
287
- nextCursor: raw.next_cursor ?? undefined,
288
- hasMore: raw.has_more,
289
- };
303
+ function transformListCommitsResult(
304
+ raw: ListCommitsResponse
305
+ ): ListCommitsResult {
306
+ return {
307
+ commits: raw.commits.map(transformCommitInfo),
308
+ nextCursor: raw.next_cursor ?? undefined,
309
+ hasMore: raw.has_more,
310
+ };
290
311
  }
291
312
 
292
313
  function normalizeDiffState(rawState: string): DiffFileState {
293
- if (!rawState) {
294
- return 'unknown';
295
- }
296
- const leading = rawState.trim()[0]?.toUpperCase();
297
- switch (leading) {
298
- case 'A':
299
- return 'added';
300
- case 'M':
301
- return 'modified';
302
- case 'D':
303
- return 'deleted';
304
- case 'R':
305
- return 'renamed';
306
- case 'C':
307
- return 'copied';
308
- case 'T':
309
- return 'type_changed';
310
- case 'U':
311
- return 'unmerged';
312
- default:
313
- return 'unknown';
314
- }
314
+ if (!rawState) {
315
+ return 'unknown';
316
+ }
317
+ const leading = rawState.trim()[0]?.toUpperCase();
318
+ switch (leading) {
319
+ case 'A':
320
+ return 'added';
321
+ case 'M':
322
+ return 'modified';
323
+ case 'D':
324
+ return 'deleted';
325
+ case 'R':
326
+ return 'renamed';
327
+ case 'C':
328
+ return 'copied';
329
+ case 'T':
330
+ return 'type_changed';
331
+ case 'U':
332
+ return 'unmerged';
333
+ default:
334
+ return 'unknown';
335
+ }
315
336
  }
316
337
 
317
338
  function transformFileDiff(raw: RawFileDiff): FileDiff {
318
- const normalizedState = normalizeDiffState(raw.state);
319
- return {
320
- path: raw.path,
321
- state: normalizedState,
322
- rawState: raw.state,
323
- oldPath: raw.old_path ?? undefined,
324
- raw: raw.raw,
325
- bytes: raw.bytes,
326
- isEof: raw.is_eof,
327
- };
339
+ const normalizedState = normalizeDiffState(raw.state);
340
+ return {
341
+ path: raw.path,
342
+ state: normalizedState,
343
+ rawState: raw.state,
344
+ oldPath: raw.old_path ?? undefined,
345
+ raw: raw.raw,
346
+ bytes: raw.bytes,
347
+ isEof: raw.is_eof,
348
+ };
328
349
  }
329
350
 
330
351
  function transformFilteredFile(raw: RawFilteredFile): FilteredFile {
331
- const normalizedState = normalizeDiffState(raw.state);
332
- return {
333
- path: raw.path,
334
- state: normalizedState,
335
- rawState: raw.state,
336
- oldPath: raw.old_path ?? undefined,
337
- bytes: raw.bytes,
338
- isEof: raw.is_eof,
339
- };
352
+ const normalizedState = normalizeDiffState(raw.state);
353
+ return {
354
+ path: raw.path,
355
+ state: normalizedState,
356
+ rawState: raw.state,
357
+ oldPath: raw.old_path ?? undefined,
358
+ bytes: raw.bytes,
359
+ isEof: raw.is_eof,
360
+ };
340
361
  }
341
362
 
342
- function transformBranchDiffResult(raw: GetBranchDiffResponse): GetBranchDiffResult {
343
- return {
344
- branch: raw.branch,
345
- base: raw.base,
346
- stats: raw.stats,
347
- files: raw.files.map(transformFileDiff),
348
- filteredFiles: raw.filtered_files.map(transformFilteredFile),
349
- };
363
+ function transformBranchDiffResult(
364
+ raw: GetBranchDiffResponse
365
+ ): GetBranchDiffResult {
366
+ return {
367
+ branch: raw.branch,
368
+ base: raw.base,
369
+ stats: raw.stats,
370
+ files: raw.files.map(transformFileDiff),
371
+ filteredFiles: raw.filtered_files.map(transformFilteredFile),
372
+ };
350
373
  }
351
374
 
352
- function transformCommitDiffResult(raw: GetCommitDiffResponse): GetCommitDiffResult {
353
- return {
354
- sha: raw.sha,
355
- stats: raw.stats,
356
- files: raw.files.map(transformFileDiff),
357
- filteredFiles: raw.filtered_files.map(transformFilteredFile),
358
- };
375
+ function transformCommitDiffResult(
376
+ raw: GetCommitDiffResponse
377
+ ): GetCommitDiffResult {
378
+ return {
379
+ sha: raw.sha,
380
+ stats: raw.stats,
381
+ files: raw.files.map(transformFileDiff),
382
+ filteredFiles: raw.filtered_files.map(transformFilteredFile),
383
+ };
359
384
  }
360
385
 
361
- function transformCreateBranchResult(raw: CreateBranchResponse): CreateBranchResult {
362
- return {
363
- message: raw.message,
364
- targetBranch: raw.target_branch,
365
- targetIsEphemeral: raw.target_is_ephemeral,
366
- commitSha: raw.commit_sha ?? undefined,
367
- };
386
+ function transformCreateBranchResult(
387
+ raw: CreateBranchResponse
388
+ ): CreateBranchResult {
389
+ return {
390
+ message: raw.message,
391
+ targetBranch: raw.target_branch,
392
+ targetIsEphemeral: raw.target_is_ephemeral,
393
+ commitSha: raw.commit_sha ?? undefined,
394
+ };
368
395
  }
369
396
 
370
397
  function transformListReposResult(raw: ListReposResponse): ListReposResult {
371
- return {
372
- repos: raw.repos.map((repo) => ({
373
- repoId: repo.repo_id,
374
- url: repo.url,
375
- defaultBranch: repo.default_branch,
376
- createdAt: repo.created_at,
377
- baseRepo: repo.base_repo
378
- ? {
379
- provider: repo.base_repo.provider,
380
- owner: repo.base_repo.owner,
381
- name: repo.base_repo.name,
382
- }
383
- : undefined,
384
- })),
385
- nextCursor: raw.next_cursor ?? undefined,
386
- hasMore: raw.has_more,
387
- };
398
+ return {
399
+ repos: raw.repos.map((repo) => ({
400
+ repoId: repo.repo_id,
401
+ url: repo.url,
402
+ defaultBranch: repo.default_branch,
403
+ createdAt: repo.created_at,
404
+ baseRepo: repo.base_repo
405
+ ? {
406
+ provider: repo.base_repo.provider,
407
+ owner: repo.base_repo.owner,
408
+ name: repo.base_repo.name,
409
+ }
410
+ : undefined,
411
+ })),
412
+ nextCursor: raw.next_cursor ?? undefined,
413
+ hasMore: raw.has_more,
414
+ };
388
415
  }
389
416
 
390
- function transformGrepLine(raw: { line_number: number; text: string; type: string }): GrepLine {
391
- return {
392
- lineNumber: raw.line_number,
393
- text: raw.text,
394
- type: raw.type,
395
- };
417
+ function transformGrepLine(raw: {
418
+ line_number: number;
419
+ text: string;
420
+ type: string;
421
+ }): GrepLine {
422
+ return {
423
+ lineNumber: raw.line_number,
424
+ text: raw.text,
425
+ type: raw.type,
426
+ };
396
427
  }
397
428
 
398
429
  function transformGrepFileMatch(raw: {
399
- path: string;
400
- lines: { line_number: number; text: string; type: string }[];
430
+ path: string;
431
+ lines: { line_number: number; text: string; type: string }[];
401
432
  }): GrepFileMatch {
402
- return {
403
- path: raw.path,
404
- lines: raw.lines.map(transformGrepLine),
405
- };
433
+ return {
434
+ path: raw.path,
435
+ lines: raw.lines.map(transformGrepLine),
436
+ };
406
437
  }
407
438
 
408
439
  function transformNoteReadResult(raw: {
409
- sha: string;
410
- note: string;
411
- ref_sha: string;
440
+ sha: string;
441
+ note: string;
442
+ ref_sha: string;
412
443
  }): GetNoteResult {
413
- return {
414
- sha: raw.sha,
415
- note: raw.note,
416
- refSha: raw.ref_sha,
417
- };
444
+ return {
445
+ sha: raw.sha,
446
+ note: raw.note,
447
+ refSha: raw.ref_sha,
448
+ };
418
449
  }
419
450
 
420
451
  function transformNoteWriteResult(raw: {
421
- sha: string;
422
- target_ref: string;
423
- base_commit?: string;
424
- new_ref_sha: string;
425
- result: { success: boolean; status: string; message?: string };
452
+ sha: string;
453
+ target_ref: string;
454
+ base_commit?: string;
455
+ new_ref_sha: string;
456
+ result: { success: boolean; status: string; message?: string };
426
457
  }): NoteWriteResult {
427
- return {
428
- sha: raw.sha,
429
- targetRef: raw.target_ref,
430
- baseCommit: raw.base_commit,
431
- newRefSha: raw.new_ref_sha,
432
- result: {
433
- success: raw.result.success,
434
- status: raw.result.status,
435
- message: raw.result.message,
436
- },
437
- };
458
+ return {
459
+ sha: raw.sha,
460
+ targetRef: raw.target_ref,
461
+ baseCommit: raw.base_commit,
462
+ newRefSha: raw.new_ref_sha,
463
+ result: {
464
+ success: raw.result.success,
465
+ status: raw.result.status,
466
+ message: raw.result.message,
467
+ },
468
+ };
438
469
  }
439
470
 
440
471
  function buildNoteWriteBody(
441
- sha: string,
442
- note: string,
443
- action: 'add' | 'append',
444
- options: { expectedRefSha?: string; author?: { name: string; email: string } },
472
+ sha: string,
473
+ note: string,
474
+ action: 'add' | 'append',
475
+ options: { expectedRefSha?: string; author?: { name: string; email: string } }
445
476
  ): Record<string, unknown> {
446
- const body: Record<string, unknown> = {
447
- sha,
448
- action,
449
- note,
450
- };
451
-
452
- const expectedRefSha = options.expectedRefSha?.trim();
453
- if (expectedRefSha) {
454
- body.expected_ref_sha = expectedRefSha;
455
- }
456
-
457
- if (options.author) {
458
- const authorName = options.author.name?.trim();
459
- const authorEmail = options.author.email?.trim();
460
- if (!authorName || !authorEmail) {
461
- throw new Error('note author name and email are required when provided');
462
- }
463
- body.author = {
464
- name: authorName,
465
- email: authorEmail,
466
- };
467
- }
468
-
469
- return body;
477
+ const body: Record<string, unknown> = {
478
+ sha,
479
+ action,
480
+ note,
481
+ };
482
+
483
+ const expectedRefSha = options.expectedRefSha?.trim();
484
+ if (expectedRefSha) {
485
+ body.expected_ref_sha = expectedRefSha;
486
+ }
487
+
488
+ if (options.author) {
489
+ const authorName = options.author.name?.trim();
490
+ const authorEmail = options.author.email?.trim();
491
+ if (!authorName || !authorEmail) {
492
+ throw new Error('note author name and email are required when provided');
493
+ }
494
+ body.author = {
495
+ name: authorName,
496
+ email: authorEmail,
497
+ };
498
+ }
499
+
500
+ return body;
470
501
  }
471
502
 
472
503
  async function parseNoteWriteResponse(
473
- response: Response,
474
- method: 'POST' | 'DELETE',
504
+ response: Response,
505
+ method: 'POST' | 'DELETE'
475
506
  ): Promise<NoteWriteResult> {
476
- let jsonBody: unknown;
477
- const contentType = response.headers.get('content-type') ?? '';
478
- try {
479
- if (contentType.includes('application/json')) {
480
- jsonBody = await response.json();
481
- } else {
482
- jsonBody = await response.text();
483
- }
484
- } catch {
485
- jsonBody = undefined;
486
- }
487
-
488
- if (jsonBody && typeof jsonBody === 'object') {
489
- const parsed = noteWriteResponseSchema.safeParse(jsonBody);
490
- if (parsed.success) {
491
- return transformNoteWriteResult(parsed.data);
492
- }
493
- const parsedError = errorEnvelopeSchema.safeParse(jsonBody);
494
- if (parsedError.success) {
495
- throw new ApiError({
496
- message: parsedError.data.error,
497
- status: response.status,
498
- statusText: response.statusText,
499
- method,
500
- url: response.url,
501
- body: jsonBody,
502
- });
503
- }
504
- }
505
-
506
- const fallbackMessage =
507
- typeof jsonBody === 'string' && jsonBody.trim() !== ''
508
- ? jsonBody.trim()
509
- : `Request ${method} ${response.url} failed with status ${response.status} ${response.statusText}`;
510
-
511
- throw new ApiError({
512
- message: fallbackMessage,
513
- status: response.status,
514
- statusText: response.statusText,
515
- method,
516
- url: response.url,
517
- body: jsonBody,
518
- });
507
+ let jsonBody: unknown;
508
+ const contentType = response.headers.get('content-type') ?? '';
509
+ try {
510
+ if (contentType.includes('application/json')) {
511
+ jsonBody = await response.json();
512
+ } else {
513
+ jsonBody = await response.text();
514
+ }
515
+ } catch {
516
+ jsonBody = undefined;
517
+ }
518
+
519
+ if (jsonBody && typeof jsonBody === 'object') {
520
+ const parsed = noteWriteResponseSchema.safeParse(jsonBody);
521
+ if (parsed.success) {
522
+ return transformNoteWriteResult(parsed.data);
523
+ }
524
+ const parsedError = errorEnvelopeSchema.safeParse(jsonBody);
525
+ if (parsedError.success) {
526
+ throw new ApiError({
527
+ message: parsedError.data.error,
528
+ status: response.status,
529
+ statusText: response.statusText,
530
+ method,
531
+ url: response.url,
532
+ body: jsonBody,
533
+ });
534
+ }
535
+ }
536
+
537
+ const fallbackMessage =
538
+ typeof jsonBody === 'string' && jsonBody.trim() !== ''
539
+ ? jsonBody.trim()
540
+ : `Request ${method} ${response.url} failed with status ${response.status} ${response.statusText}`;
541
+
542
+ throw new ApiError({
543
+ message: fallbackMessage,
544
+ status: response.status,
545
+ statusText: response.statusText,
546
+ method,
547
+ url: response.url,
548
+ body: jsonBody,
549
+ });
519
550
  }
520
551
 
521
552
  /**
522
553
  * Implementation of the Repo interface
523
554
  */
524
555
  class RepoImpl implements Repo {
525
- private readonly api: ApiFetcher;
526
-
527
- constructor(
528
- public readonly id: string,
529
- public readonly defaultBranch: string,
530
- private readonly options: GitStorageOptions,
531
- private readonly generateJWT: (
532
- repoId: string,
533
- options?: GetRemoteURLOptions,
534
- ) => Promise<string>,
535
- ) {
536
- this.api = getApiInstance(
537
- this.options.apiBaseUrl ?? GitStorage.getDefaultAPIBaseUrl(options.name),
538
- this.options.apiVersion ?? API_VERSION,
539
- );
540
- }
541
-
542
- async getRemoteURL(urlOptions?: GetRemoteURLOptions): Promise<string> {
543
- const url = new URL(`https://${this.options.storageBaseUrl}/${this.id}.git`);
544
- url.username = `t`;
545
- url.password = await this.generateJWT(this.id, urlOptions);
546
- return url.toString();
547
- }
548
-
549
- async getEphemeralRemoteURL(urlOptions?: GetRemoteURLOptions): Promise<string> {
550
- const url = new URL(`https://${this.options.storageBaseUrl}/${this.id}+ephemeral.git`);
551
- url.username = `t`;
552
- url.password = await this.generateJWT(this.id, urlOptions);
553
- return url.toString();
554
- }
555
-
556
- async getFileStream(options: GetFileOptions): Promise<Response> {
557
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
558
- const jwt = await this.generateJWT(this.id, {
559
- permissions: ['git:read'],
560
- ttl,
561
- });
562
-
563
- const params: Record<string, string> = {
564
- path: options.path,
565
- };
566
-
567
- if (options.ref) {
568
- params.ref = options.ref;
569
- }
570
- if (typeof options.ephemeral === 'boolean') {
571
- params.ephemeral = String(options.ephemeral);
572
- }
573
- if (typeof options.ephemeralBase === 'boolean') {
574
- params.ephemeral_base = String(options.ephemeralBase);
575
- }
576
-
577
- // Return the raw fetch Response for streaming
578
- return this.api.get({ path: 'repos/file', params }, jwt);
579
- }
580
-
581
- async listFiles(options?: ListFilesOptions): Promise<ListFilesResult> {
582
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
583
- const jwt = await this.generateJWT(this.id, {
584
- permissions: ['git:read'],
585
- ttl,
586
- });
587
-
588
- const params: Record<string, string> = {};
589
- if (options?.ref) {
590
- params.ref = options.ref;
591
- }
592
- if (typeof options?.ephemeral === 'boolean') {
593
- params.ephemeral = String(options.ephemeral);
594
- }
595
- const response = await this.api.get(
596
- { path: 'repos/files', params: Object.keys(params).length ? params : undefined },
597
- jwt,
598
- );
599
-
600
- const raw = listFilesResponseSchema.parse(await response.json());
601
- return { paths: raw.paths, ref: raw.ref };
602
- }
603
-
604
- async listBranches(options?: ListBranchesOptions): Promise<ListBranchesResult> {
605
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
606
- const jwt = await this.generateJWT(this.id, {
607
- permissions: ['git:read'],
608
- ttl,
609
- });
610
-
611
- const cursor = options?.cursor;
612
- const limit = options?.limit;
613
-
614
- let params: Record<string, string> | undefined;
615
-
616
- if (typeof cursor === 'string' || typeof limit === 'number') {
617
- params = {};
618
- if (typeof cursor === 'string') {
619
- params.cursor = cursor;
620
- }
621
- if (typeof limit === 'number') {
622
- params.limit = limit.toString();
623
- }
624
- }
625
-
626
- const response = await this.api.get({ path: 'repos/branches', params }, jwt);
627
-
628
- const raw = listBranchesResponseSchema.parse(await response.json());
629
- return transformListBranchesResult({
630
- ...raw,
631
- next_cursor: raw.next_cursor ?? undefined,
632
- });
633
- }
634
-
635
- async listCommits(options?: ListCommitsOptions): Promise<ListCommitsResult> {
636
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
637
- const jwt = await this.generateJWT(this.id, {
638
- permissions: ['git:read'],
639
- ttl,
640
- });
641
-
642
- let params: Record<string, string> | undefined;
643
-
644
- if (options?.branch || options?.cursor || options?.limit) {
645
- params = {};
646
- if (options?.branch) {
647
- params.branch = options.branch;
648
- }
649
- if (options?.cursor) {
650
- params.cursor = options.cursor;
651
- }
652
- if (typeof options?.limit == 'number') {
653
- params.limit = options.limit.toString();
654
- }
655
- }
656
-
657
- const response = await this.api.get({ path: 'repos/commits', params }, jwt);
658
-
659
- const raw = listCommitsResponseSchema.parse(await response.json());
660
- return transformListCommitsResult({
661
- ...raw,
662
- next_cursor: raw.next_cursor ?? undefined,
663
- });
664
- }
665
-
666
- async getNote(options: GetNoteOptions): Promise<GetNoteResult> {
667
- const sha = options?.sha?.trim();
668
- if (!sha) {
669
- throw new Error('getNote sha is required');
670
- }
671
-
672
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
673
- const jwt = await this.generateJWT(this.id, {
674
- permissions: ['git:read'],
675
- ttl,
676
- });
677
-
678
- const response = await this.api.get({ path: 'repos/notes', params: { sha } }, jwt);
679
- const raw = noteReadResponseSchema.parse(await response.json());
680
- return transformNoteReadResult(raw);
681
- }
682
-
683
- async createNote(options: CreateNoteOptions): Promise<NoteWriteResult> {
684
- const sha = options?.sha?.trim();
685
- if (!sha) {
686
- throw new Error('createNote sha is required');
687
- }
688
-
689
- const note = options?.note?.trim();
690
- if (!note) {
691
- throw new Error('createNote note is required');
692
- }
693
-
694
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
695
- const jwt = await this.generateJWT(this.id, {
696
- permissions: ['git:write'],
697
- ttl,
698
- });
699
-
700
- const body = buildNoteWriteBody(sha, note, 'add', {
701
- expectedRefSha: options.expectedRefSha,
702
- author: options.author,
703
- });
704
-
705
- const response = await this.api.post({ path: 'repos/notes', body }, jwt, {
706
- allowedStatus: [...NOTE_WRITE_ALLOWED_STATUS],
707
- });
708
-
709
- const result = await parseNoteWriteResponse(response, 'POST');
710
- if (!result.result.success) {
711
- throw new RefUpdateError(
712
- result.result.message ?? `createNote failed with status ${result.result.status}`,
713
- {
714
- status: result.result.status,
715
- message: result.result.message,
716
- refUpdate: toPartialRefUpdate(result.targetRef, result.baseCommit, result.newRefSha),
717
- },
718
- );
719
- }
720
- return result;
721
- }
722
-
723
- async appendNote(options: AppendNoteOptions): Promise<NoteWriteResult> {
724
- const sha = options?.sha?.trim();
725
- if (!sha) {
726
- throw new Error('appendNote sha is required');
727
- }
728
-
729
- const note = options?.note?.trim();
730
- if (!note) {
731
- throw new Error('appendNote note is required');
732
- }
733
-
734
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
735
- const jwt = await this.generateJWT(this.id, {
736
- permissions: ['git:write'],
737
- ttl,
738
- });
739
-
740
- const body = buildNoteWriteBody(sha, note, 'append', {
741
- expectedRefSha: options.expectedRefSha,
742
- author: options.author,
743
- });
744
-
745
- const response = await this.api.post({ path: 'repos/notes', body }, jwt, {
746
- allowedStatus: [...NOTE_WRITE_ALLOWED_STATUS],
747
- });
748
-
749
- const result = await parseNoteWriteResponse(response, 'POST');
750
- if (!result.result.success) {
751
- throw new RefUpdateError(
752
- result.result.message ?? `appendNote failed with status ${result.result.status}`,
753
- {
754
- status: result.result.status,
755
- message: result.result.message,
756
- refUpdate: toPartialRefUpdate(result.targetRef, result.baseCommit, result.newRefSha),
757
- },
758
- );
759
- }
760
- return result;
761
- }
762
-
763
- async deleteNote(options: DeleteNoteOptions): Promise<NoteWriteResult> {
764
- const sha = options?.sha?.trim();
765
- if (!sha) {
766
- throw new Error('deleteNote sha is required');
767
- }
768
-
769
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
770
- const jwt = await this.generateJWT(this.id, {
771
- permissions: ['git:write'],
772
- ttl,
773
- });
774
-
775
- const body: Record<string, unknown> = {
776
- sha,
777
- };
778
-
779
- const expectedRefSha = options.expectedRefSha?.trim();
780
- if (expectedRefSha) {
781
- body.expected_ref_sha = expectedRefSha;
782
- }
783
-
784
- if (options.author) {
785
- const authorName = options.author.name?.trim();
786
- const authorEmail = options.author.email?.trim();
787
- if (!authorName || !authorEmail) {
788
- throw new Error('deleteNote author name and email are required when provided');
789
- }
790
- body.author = {
791
- name: authorName,
792
- email: authorEmail,
793
- };
794
- }
795
-
796
- const response = await this.api.delete({ path: 'repos/notes', body }, jwt, {
797
- allowedStatus: [...NOTE_WRITE_ALLOWED_STATUS],
798
- });
799
-
800
- const result = await parseNoteWriteResponse(response, 'DELETE');
801
- if (!result.result.success) {
802
- throw new RefUpdateError(
803
- result.result.message ?? `deleteNote failed with status ${result.result.status}`,
804
- {
805
- status: result.result.status,
806
- message: result.result.message,
807
- refUpdate: toPartialRefUpdate(result.targetRef, result.baseCommit, result.newRefSha),
808
- },
809
- );
810
- }
811
- return result;
812
- }
813
-
814
- async getBranchDiff(options: GetBranchDiffOptions): Promise<GetBranchDiffResult> {
815
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
816
- const jwt = await this.generateJWT(this.id, {
817
- permissions: ['git:read'],
818
- ttl,
819
- });
820
-
821
- const params: Record<string, string | string[]> = {
822
- branch: options.branch,
823
- };
824
-
825
- if (options.base) {
826
- params.base = options.base;
827
- }
828
- if (typeof options.ephemeral === 'boolean') {
829
- params.ephemeral = String(options.ephemeral);
830
- }
831
- if (typeof options.ephemeralBase === 'boolean') {
832
- params.ephemeral_base = String(options.ephemeralBase);
833
- }
834
- if (options.paths && options.paths.length > 0) {
835
- params.path = options.paths;
836
- }
837
-
838
- const response = await this.api.get({ path: 'repos/branches/diff', params }, jwt);
839
-
840
- const raw = branchDiffResponseSchema.parse(await response.json());
841
- return transformBranchDiffResult(raw);
842
- }
843
-
844
- async getCommitDiff(options: GetCommitDiffOptions): Promise<GetCommitDiffResult> {
845
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
846
- const jwt = await this.generateJWT(this.id, {
847
- permissions: ['git:read'],
848
- ttl,
849
- });
850
-
851
- const params: Record<string, string | string[]> = {
852
- sha: options.sha,
853
- };
854
-
855
- if (options.baseSha) {
856
- params.baseSha = options.baseSha;
857
- }
858
- if (options.paths && options.paths.length > 0) {
859
- params.path = options.paths;
860
- }
861
-
862
- const response = await this.api.get({ path: 'repos/diff', params }, jwt);
863
-
864
- const raw = commitDiffResponseSchema.parse(await response.json());
865
- return transformCommitDiffResult(raw);
866
- }
867
-
868
- async grep(options: GrepOptions): Promise<GrepResult> {
869
- const pattern = options?.query?.pattern?.trim();
870
- if (!pattern) {
871
- throw new Error('grep query.pattern is required');
872
- }
873
-
874
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
875
- const jwt = await this.generateJWT(this.id, {
876
- permissions: ['git:read'],
877
- ttl,
878
- });
879
-
880
- const body: Record<string, unknown> = {
881
- query: {
882
- pattern,
883
- ...(typeof options.query.caseSensitive === 'boolean'
884
- ? { case_sensitive: options.query.caseSensitive }
885
- : {}),
886
- },
887
- };
888
-
889
- if (options.ref) {
890
- body.rev = options.ref;
891
- }
892
- if (Array.isArray(options.paths) && options.paths.length > 0) {
893
- body.paths = options.paths;
894
- }
895
- if (options.fileFilters) {
896
- body.file_filters = {
897
- ...(options.fileFilters.includeGlobs
898
- ? { include_globs: options.fileFilters.includeGlobs }
899
- : {}),
900
- ...(options.fileFilters.excludeGlobs
901
- ? { exclude_globs: options.fileFilters.excludeGlobs }
902
- : {}),
903
- ...(options.fileFilters.extensionFilters
904
- ? { extension_filters: options.fileFilters.extensionFilters }
905
- : {}),
906
- };
907
- }
908
- if (options.context) {
909
- body.context = {
910
- ...(typeof options.context.before === 'number' ? { before: options.context.before } : {}),
911
- ...(typeof options.context.after === 'number' ? { after: options.context.after } : {}),
912
- };
913
- }
914
- if (options.limits) {
915
- body.limits = {
916
- ...(typeof options.limits.maxLines === 'number'
917
- ? { max_lines: options.limits.maxLines }
918
- : {}),
919
- ...(typeof options.limits.maxMatchesPerFile === 'number'
920
- ? { max_matches_per_file: options.limits.maxMatchesPerFile }
921
- : {}),
922
- };
923
- }
924
- if (options.pagination) {
925
- body.pagination = {
926
- ...(typeof options.pagination.cursor === 'string' && options.pagination.cursor.trim() !== ''
927
- ? { cursor: options.pagination.cursor }
928
- : {}),
929
- ...(typeof options.pagination.limit === 'number'
930
- ? { limit: options.pagination.limit }
931
- : {}),
932
- };
933
- }
934
-
935
- const response = await this.api.post({ path: 'repos/grep', body }, jwt);
936
- const raw = grepResponseSchema.parse(await response.json());
937
-
938
- return {
939
- query: {
940
- pattern: raw.query.pattern,
941
- caseSensitive: raw.query.case_sensitive,
942
- },
943
- repo: {
944
- ref: raw.repo.ref,
945
- commit: raw.repo.commit,
946
- },
947
- matches: raw.matches.map(transformGrepFileMatch),
948
- nextCursor: raw.next_cursor ?? undefined,
949
- hasMore: raw.has_more,
950
- };
951
- }
952
-
953
- async pullUpstream(options: PullUpstreamOptions = {}): Promise<void> {
954
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
955
- const jwt = await this.generateJWT(this.id, {
956
- permissions: ['git:write'],
957
- ttl,
958
- });
959
-
960
- const body: Record<string, string> = {};
961
-
962
- if (options.ref) {
963
- body.ref = options.ref;
964
- }
965
-
966
- const response = await this.api.post({ path: 'repos/pull-upstream', body }, jwt);
967
-
968
- if (response.status !== 202) {
969
- throw new Error(`Pull Upstream failed: ${response.status} ${await response.text()}`);
970
- }
971
-
972
- return;
973
- }
974
-
975
- async createBranch(options: CreateBranchOptions): Promise<CreateBranchResult> {
976
- const baseBranch = options?.baseBranch?.trim();
977
- if (!baseBranch) {
978
- throw new Error('createBranch baseBranch is required');
979
- }
980
- const targetBranch = options?.targetBranch?.trim();
981
- if (!targetBranch) {
982
- throw new Error('createBranch targetBranch is required');
983
- }
984
-
985
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
986
- const jwt = await this.generateJWT(this.id, {
987
- permissions: ['git:write'],
988
- ttl,
989
- });
990
-
991
- const body: Record<string, unknown> = {
992
- base_branch: baseBranch,
993
- target_branch: targetBranch,
994
- };
995
-
996
- if (options.baseIsEphemeral === true) {
997
- body.base_is_ephemeral = true;
998
- }
999
- if (options.targetIsEphemeral === true) {
1000
- body.target_is_ephemeral = true;
1001
- }
1002
-
1003
- const response = await this.api.post({ path: 'repos/branches/create', body }, jwt);
1004
- const raw = createBranchResponseSchema.parse(await response.json());
1005
- return transformCreateBranchResult(raw);
1006
- }
1007
-
1008
- async restoreCommit(options: RestoreCommitOptions): Promise<RestoreCommitResult> {
1009
- const targetBranch = options?.targetBranch?.trim();
1010
- if (!targetBranch) {
1011
- throw new Error('restoreCommit targetBranch is required');
1012
- }
1013
- if (targetBranch.startsWith('refs/')) {
1014
- throw new Error('restoreCommit targetBranch must not include refs/ prefix');
1015
- }
1016
-
1017
- const targetCommitSha = options?.targetCommitSha?.trim();
1018
- if (!targetCommitSha) {
1019
- throw new Error('restoreCommit targetCommitSha is required');
1020
- }
1021
- const commitMessage = options?.commitMessage?.trim();
1022
-
1023
- const authorName = options.author?.name?.trim();
1024
- const authorEmail = options.author?.email?.trim();
1025
- if (!authorName || !authorEmail) {
1026
- throw new Error('restoreCommit author name and email are required');
1027
- }
1028
-
1029
- const ttl = resolveCommitTtlSeconds(options);
1030
- const jwt = await this.generateJWT(this.id, {
1031
- permissions: ['git:write'],
1032
- ttl,
1033
- });
1034
-
1035
- const metadata: Record<string, unknown> = {
1036
- target_branch: targetBranch,
1037
- target_commit_sha: targetCommitSha,
1038
- author: {
1039
- name: authorName,
1040
- email: authorEmail,
1041
- },
1042
- };
1043
-
1044
- if (commitMessage) {
1045
- metadata.commit_message = commitMessage;
1046
- }
1047
-
1048
- const expectedHeadSha = options.expectedHeadSha?.trim();
1049
- if (expectedHeadSha) {
1050
- metadata.expected_head_sha = expectedHeadSha;
1051
- }
1052
-
1053
- if (options.committer) {
1054
- const committerName = options.committer.name?.trim();
1055
- const committerEmail = options.committer.email?.trim();
1056
- if (!committerName || !committerEmail) {
1057
- throw new Error('restoreCommit committer name and email are required when provided');
1058
- }
1059
- metadata.committer = {
1060
- name: committerName,
1061
- email: committerEmail,
1062
- };
1063
- }
1064
-
1065
- const response = await this.api.post(
1066
- { path: 'repos/restore-commit', body: { metadata } },
1067
- jwt,
1068
- {
1069
- allowedStatus: [...RESTORE_COMMIT_ALLOWED_STATUS],
1070
- },
1071
- );
1072
-
1073
- const payload = await response.json();
1074
- const parsed = parseRestoreCommitPayload(payload);
1075
- if (parsed && 'ack' in parsed) {
1076
- return buildRestoreCommitResult(parsed.ack);
1077
- }
1078
-
1079
- const failure = parsed && 'failure' in parsed ? parsed.failure : undefined;
1080
- const status = failure?.status ?? httpStatusToRestoreStatus(response.status);
1081
- const message =
1082
- failure?.message ??
1083
- `Restore commit failed with HTTP ${response.status}` +
1084
- (response.statusText ? ` ${response.statusText}` : '');
1085
-
1086
- throw new RefUpdateError(message, {
1087
- status,
1088
- refUpdate: failure?.refUpdate,
1089
- });
1090
- }
1091
-
1092
- createCommit(options: CreateCommitOptions): CommitBuilder {
1093
- const version = this.options.apiVersion ?? API_VERSION;
1094
- const baseUrl = this.options.apiBaseUrl ?? GitStorage.getDefaultAPIBaseUrl(this.options.name);
1095
- const transport = new FetchCommitTransport({ baseUrl, version });
1096
- const ttl = resolveCommitTtlSeconds(options);
1097
- const builderOptions: CreateCommitOptions = {
1098
- ...options,
1099
- ttl,
1100
- };
1101
- const getAuthToken = () =>
1102
- this.generateJWT(this.id, {
1103
- permissions: ['git:write'],
1104
- ttl,
1105
- });
1106
-
1107
- return createCommitBuilder({
1108
- options: builderOptions,
1109
- getAuthToken,
1110
- transport,
1111
- });
1112
- }
1113
-
1114
- async createCommitFromDiff(options: CreateCommitFromDiffOptions): Promise<CommitResult> {
1115
- const version = this.options.apiVersion ?? API_VERSION;
1116
- const baseUrl = this.options.apiBaseUrl ?? GitStorage.getDefaultAPIBaseUrl(this.options.name);
1117
- const transport = new FetchDiffCommitTransport({ baseUrl, version });
1118
- const ttl = resolveCommitTtlSeconds(options);
1119
- const requestOptions: CreateCommitFromDiffOptions = {
1120
- ...options,
1121
- ttl,
1122
- };
1123
- const getAuthToken = () =>
1124
- this.generateJWT(this.id, {
1125
- permissions: ['git:write'],
1126
- ttl,
1127
- });
1128
-
1129
- return sendCommitFromDiff({
1130
- options: requestOptions,
1131
- getAuthToken,
1132
- transport,
1133
- });
1134
- }
556
+ private readonly api: ApiFetcher;
557
+
558
+ constructor(
559
+ public readonly id: string,
560
+ public readonly defaultBranch: string,
561
+ private readonly options: GitStorageOptions,
562
+ private readonly generateJWT: (
563
+ repoId: string,
564
+ options?: GetRemoteURLOptions
565
+ ) => Promise<string>
566
+ ) {
567
+ this.api = getApiInstance(
568
+ this.options.apiBaseUrl ?? GitStorage.getDefaultAPIBaseUrl(options.name),
569
+ this.options.apiVersion ?? API_VERSION
570
+ );
571
+ }
572
+
573
+ async getRemoteURL(urlOptions?: GetRemoteURLOptions): Promise<string> {
574
+ const url = new URL(
575
+ `https://${this.options.storageBaseUrl}/${this.id}.git`
576
+ );
577
+ url.username = `t`;
578
+ url.password = await this.generateJWT(this.id, urlOptions);
579
+ return url.toString();
580
+ }
581
+
582
+ async getEphemeralRemoteURL(
583
+ urlOptions?: GetRemoteURLOptions
584
+ ): Promise<string> {
585
+ const url = new URL(
586
+ `https://${this.options.storageBaseUrl}/${this.id}+ephemeral.git`
587
+ );
588
+ url.username = `t`;
589
+ url.password = await this.generateJWT(this.id, urlOptions);
590
+ return url.toString();
591
+ }
592
+
593
+ async getFileStream(options: GetFileOptions): Promise<Response> {
594
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
595
+ const jwt = await this.generateJWT(this.id, {
596
+ permissions: ['git:read'],
597
+ ttl,
598
+ });
599
+
600
+ const params: Record<string, string> = {
601
+ path: options.path,
602
+ };
603
+
604
+ if (options.ref) {
605
+ params.ref = options.ref;
606
+ }
607
+ if (typeof options.ephemeral === 'boolean') {
608
+ params.ephemeral = String(options.ephemeral);
609
+ }
610
+ if (typeof options.ephemeralBase === 'boolean') {
611
+ params.ephemeral_base = String(options.ephemeralBase);
612
+ }
613
+
614
+ // Return the raw fetch Response for streaming
615
+ return this.api.get({ path: 'repos/file', params }, jwt);
616
+ }
617
+
618
+ async getArchiveStream(options: ArchiveOptions = {}): Promise<Response> {
619
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
620
+ const jwt = await this.generateJWT(this.id, {
621
+ permissions: ['git:read'],
622
+ ttl,
623
+ });
624
+
625
+ const body: Record<string, unknown> = {};
626
+ const ref = options.ref?.trim();
627
+ if (ref) {
628
+ body.ref = ref;
629
+ }
630
+ if (Array.isArray(options.includeGlobs) && options.includeGlobs.length > 0) {
631
+ body.include_globs = options.includeGlobs;
632
+ }
633
+ if (Array.isArray(options.excludeGlobs) && options.excludeGlobs.length > 0) {
634
+ body.exclude_globs = options.excludeGlobs;
635
+ }
636
+ if (typeof options.archivePrefix === 'string') {
637
+ const prefix = options.archivePrefix.trim();
638
+ if (prefix) {
639
+ body.archive = { prefix };
640
+ }
641
+ }
642
+
643
+ const path =
644
+ Object.keys(body).length > 0 ? { path: 'repos/archive', body } : 'repos/archive';
645
+
646
+ return this.api.post(path, jwt);
647
+ }
648
+
649
+ async listFiles(options?: ListFilesOptions): Promise<ListFilesResult> {
650
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
651
+ const jwt = await this.generateJWT(this.id, {
652
+ permissions: ['git:read'],
653
+ ttl,
654
+ });
655
+
656
+ const params: Record<string, string> = {};
657
+ if (options?.ref) {
658
+ params.ref = options.ref;
659
+ }
660
+ if (typeof options?.ephemeral === 'boolean') {
661
+ params.ephemeral = String(options.ephemeral);
662
+ }
663
+ const response = await this.api.get(
664
+ {
665
+ path: 'repos/files',
666
+ params: Object.keys(params).length ? params : undefined,
667
+ },
668
+ jwt
669
+ );
670
+
671
+ const raw = listFilesResponseSchema.parse(await response.json());
672
+ return { paths: raw.paths, ref: raw.ref };
673
+ }
674
+
675
+ async listBranches(
676
+ options?: ListBranchesOptions
677
+ ): Promise<ListBranchesResult> {
678
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
679
+ const jwt = await this.generateJWT(this.id, {
680
+ permissions: ['git:read'],
681
+ ttl,
682
+ });
683
+
684
+ const cursor = options?.cursor;
685
+ const limit = options?.limit;
686
+
687
+ let params: Record<string, string> | undefined;
688
+
689
+ if (typeof cursor === 'string' || typeof limit === 'number') {
690
+ params = {};
691
+ if (typeof cursor === 'string') {
692
+ params.cursor = cursor;
693
+ }
694
+ if (typeof limit === 'number') {
695
+ params.limit = limit.toString();
696
+ }
697
+ }
698
+
699
+ const response = await this.api.get(
700
+ { path: 'repos/branches', params },
701
+ jwt
702
+ );
703
+
704
+ const raw = listBranchesResponseSchema.parse(await response.json());
705
+ return transformListBranchesResult({
706
+ ...raw,
707
+ next_cursor: raw.next_cursor ?? undefined,
708
+ });
709
+ }
710
+
711
+ async listCommits(options?: ListCommitsOptions): Promise<ListCommitsResult> {
712
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
713
+ const jwt = await this.generateJWT(this.id, {
714
+ permissions: ['git:read'],
715
+ ttl,
716
+ });
717
+
718
+ let params: Record<string, string> | undefined;
719
+
720
+ if (options?.branch || options?.cursor || options?.limit) {
721
+ params = {};
722
+ if (options?.branch) {
723
+ params.branch = options.branch;
724
+ }
725
+ if (options?.cursor) {
726
+ params.cursor = options.cursor;
727
+ }
728
+ if (typeof options?.limit == 'number') {
729
+ params.limit = options.limit.toString();
730
+ }
731
+ }
732
+
733
+ const response = await this.api.get({ path: 'repos/commits', params }, jwt);
734
+
735
+ const raw = listCommitsResponseSchema.parse(await response.json());
736
+ return transformListCommitsResult({
737
+ ...raw,
738
+ next_cursor: raw.next_cursor ?? undefined,
739
+ });
740
+ }
741
+
742
+ async getNote(options: GetNoteOptions): Promise<GetNoteResult> {
743
+ const sha = options?.sha?.trim();
744
+ if (!sha) {
745
+ throw new Error('getNote sha is required');
746
+ }
747
+
748
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
749
+ const jwt = await this.generateJWT(this.id, {
750
+ permissions: ['git:read'],
751
+ ttl,
752
+ });
753
+
754
+ const response = await this.api.get(
755
+ { path: 'repos/notes', params: { sha } },
756
+ jwt
757
+ );
758
+ const raw = noteReadResponseSchema.parse(await response.json());
759
+ return transformNoteReadResult(raw);
760
+ }
761
+
762
+ async createNote(options: CreateNoteOptions): Promise<NoteWriteResult> {
763
+ const sha = options?.sha?.trim();
764
+ if (!sha) {
765
+ throw new Error('createNote sha is required');
766
+ }
767
+
768
+ const note = options?.note?.trim();
769
+ if (!note) {
770
+ throw new Error('createNote note is required');
771
+ }
772
+
773
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
774
+ const jwt = await this.generateJWT(this.id, {
775
+ permissions: ['git:write'],
776
+ ttl,
777
+ });
778
+
779
+ const body = buildNoteWriteBody(sha, note, 'add', {
780
+ expectedRefSha: options.expectedRefSha,
781
+ author: options.author,
782
+ });
783
+
784
+ const response = await this.api.post({ path: 'repos/notes', body }, jwt, {
785
+ allowedStatus: [...NOTE_WRITE_ALLOWED_STATUS],
786
+ });
787
+
788
+ const result = await parseNoteWriteResponse(response, 'POST');
789
+ if (!result.result.success) {
790
+ throw new RefUpdateError(
791
+ result.result.message ??
792
+ `createNote failed with status ${result.result.status}`,
793
+ {
794
+ status: result.result.status,
795
+ message: result.result.message,
796
+ refUpdate: toPartialRefUpdate(
797
+ result.targetRef,
798
+ result.baseCommit,
799
+ result.newRefSha
800
+ ),
801
+ }
802
+ );
803
+ }
804
+ return result;
805
+ }
806
+
807
+ async appendNote(options: AppendNoteOptions): Promise<NoteWriteResult> {
808
+ const sha = options?.sha?.trim();
809
+ if (!sha) {
810
+ throw new Error('appendNote sha is required');
811
+ }
812
+
813
+ const note = options?.note?.trim();
814
+ if (!note) {
815
+ throw new Error('appendNote note is required');
816
+ }
817
+
818
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
819
+ const jwt = await this.generateJWT(this.id, {
820
+ permissions: ['git:write'],
821
+ ttl,
822
+ });
823
+
824
+ const body = buildNoteWriteBody(sha, note, 'append', {
825
+ expectedRefSha: options.expectedRefSha,
826
+ author: options.author,
827
+ });
828
+
829
+ const response = await this.api.post({ path: 'repos/notes', body }, jwt, {
830
+ allowedStatus: [...NOTE_WRITE_ALLOWED_STATUS],
831
+ });
832
+
833
+ const result = await parseNoteWriteResponse(response, 'POST');
834
+ if (!result.result.success) {
835
+ throw new RefUpdateError(
836
+ result.result.message ??
837
+ `appendNote failed with status ${result.result.status}`,
838
+ {
839
+ status: result.result.status,
840
+ message: result.result.message,
841
+ refUpdate: toPartialRefUpdate(
842
+ result.targetRef,
843
+ result.baseCommit,
844
+ result.newRefSha
845
+ ),
846
+ }
847
+ );
848
+ }
849
+ return result;
850
+ }
851
+
852
+ async deleteNote(options: DeleteNoteOptions): Promise<NoteWriteResult> {
853
+ const sha = options?.sha?.trim();
854
+ if (!sha) {
855
+ throw new Error('deleteNote sha is required');
856
+ }
857
+
858
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
859
+ const jwt = await this.generateJWT(this.id, {
860
+ permissions: ['git:write'],
861
+ ttl,
862
+ });
863
+
864
+ const body: Record<string, unknown> = {
865
+ sha,
866
+ };
867
+
868
+ const expectedRefSha = options.expectedRefSha?.trim();
869
+ if (expectedRefSha) {
870
+ body.expected_ref_sha = expectedRefSha;
871
+ }
872
+
873
+ if (options.author) {
874
+ const authorName = options.author.name?.trim();
875
+ const authorEmail = options.author.email?.trim();
876
+ if (!authorName || !authorEmail) {
877
+ throw new Error(
878
+ 'deleteNote author name and email are required when provided'
879
+ );
880
+ }
881
+ body.author = {
882
+ name: authorName,
883
+ email: authorEmail,
884
+ };
885
+ }
886
+
887
+ const response = await this.api.delete({ path: 'repos/notes', body }, jwt, {
888
+ allowedStatus: [...NOTE_WRITE_ALLOWED_STATUS],
889
+ });
890
+
891
+ const result = await parseNoteWriteResponse(response, 'DELETE');
892
+ if (!result.result.success) {
893
+ throw new RefUpdateError(
894
+ result.result.message ??
895
+ `deleteNote failed with status ${result.result.status}`,
896
+ {
897
+ status: result.result.status,
898
+ message: result.result.message,
899
+ refUpdate: toPartialRefUpdate(
900
+ result.targetRef,
901
+ result.baseCommit,
902
+ result.newRefSha
903
+ ),
904
+ }
905
+ );
906
+ }
907
+ return result;
908
+ }
909
+
910
+ async getBranchDiff(
911
+ options: GetBranchDiffOptions
912
+ ): Promise<GetBranchDiffResult> {
913
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
914
+ const jwt = await this.generateJWT(this.id, {
915
+ permissions: ['git:read'],
916
+ ttl,
917
+ });
918
+
919
+ const params: Record<string, string | string[]> = {
920
+ branch: options.branch,
921
+ };
922
+
923
+ if (options.base) {
924
+ params.base = options.base;
925
+ }
926
+ if (typeof options.ephemeral === 'boolean') {
927
+ params.ephemeral = String(options.ephemeral);
928
+ }
929
+ if (typeof options.ephemeralBase === 'boolean') {
930
+ params.ephemeral_base = String(options.ephemeralBase);
931
+ }
932
+ if (options.paths && options.paths.length > 0) {
933
+ params.path = options.paths;
934
+ }
935
+
936
+ const response = await this.api.get(
937
+ { path: 'repos/branches/diff', params },
938
+ jwt
939
+ );
940
+
941
+ const raw = branchDiffResponseSchema.parse(await response.json());
942
+ return transformBranchDiffResult(raw);
943
+ }
944
+
945
+ async getCommitDiff(
946
+ options: GetCommitDiffOptions
947
+ ): Promise<GetCommitDiffResult> {
948
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
949
+ const jwt = await this.generateJWT(this.id, {
950
+ permissions: ['git:read'],
951
+ ttl,
952
+ });
953
+
954
+ const params: Record<string, string | string[]> = {
955
+ sha: options.sha,
956
+ };
957
+
958
+ if (options.baseSha) {
959
+ params.baseSha = options.baseSha;
960
+ }
961
+ if (options.paths && options.paths.length > 0) {
962
+ params.path = options.paths;
963
+ }
964
+
965
+ const response = await this.api.get({ path: 'repos/diff', params }, jwt);
966
+
967
+ const raw = commitDiffResponseSchema.parse(await response.json());
968
+ return transformCommitDiffResult(raw);
969
+ }
970
+
971
+ async grep(options: GrepOptions): Promise<GrepResult> {
972
+ const pattern = options?.query?.pattern?.trim();
973
+ if (!pattern) {
974
+ throw new Error('grep query.pattern is required');
975
+ }
976
+
977
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
978
+ const jwt = await this.generateJWT(this.id, {
979
+ permissions: ['git:read'],
980
+ ttl,
981
+ });
982
+
983
+ const body: Record<string, unknown> = {
984
+ query: {
985
+ pattern,
986
+ ...(typeof options.query.caseSensitive === 'boolean'
987
+ ? { case_sensitive: options.query.caseSensitive }
988
+ : {}),
989
+ },
990
+ };
991
+
992
+ const ref = options.ref?.trim() || options.rev?.trim();
993
+ if (ref) {
994
+ body.ref = ref;
995
+ }
996
+ if (Array.isArray(options.paths) && options.paths.length > 0) {
997
+ body.paths = options.paths;
998
+ }
999
+ if (options.fileFilters) {
1000
+ body.file_filters = {
1001
+ ...(options.fileFilters.includeGlobs
1002
+ ? { include_globs: options.fileFilters.includeGlobs }
1003
+ : {}),
1004
+ ...(options.fileFilters.excludeGlobs
1005
+ ? { exclude_globs: options.fileFilters.excludeGlobs }
1006
+ : {}),
1007
+ ...(options.fileFilters.extensionFilters
1008
+ ? { extension_filters: options.fileFilters.extensionFilters }
1009
+ : {}),
1010
+ };
1011
+ }
1012
+ if (options.context) {
1013
+ body.context = {
1014
+ ...(typeof options.context.before === 'number'
1015
+ ? { before: options.context.before }
1016
+ : {}),
1017
+ ...(typeof options.context.after === 'number'
1018
+ ? { after: options.context.after }
1019
+ : {}),
1020
+ };
1021
+ }
1022
+ if (options.limits) {
1023
+ body.limits = {
1024
+ ...(typeof options.limits.maxLines === 'number'
1025
+ ? { max_lines: options.limits.maxLines }
1026
+ : {}),
1027
+ ...(typeof options.limits.maxMatchesPerFile === 'number'
1028
+ ? { max_matches_per_file: options.limits.maxMatchesPerFile }
1029
+ : {}),
1030
+ };
1031
+ }
1032
+ if (options.pagination) {
1033
+ body.pagination = {
1034
+ ...(typeof options.pagination.cursor === 'string' &&
1035
+ options.pagination.cursor.trim() !== ''
1036
+ ? { cursor: options.pagination.cursor }
1037
+ : {}),
1038
+ ...(typeof options.pagination.limit === 'number'
1039
+ ? { limit: options.pagination.limit }
1040
+ : {}),
1041
+ };
1042
+ }
1043
+
1044
+ const response = await this.api.post({ path: 'repos/grep', body }, jwt);
1045
+ const raw = grepResponseSchema.parse(await response.json());
1046
+
1047
+ return {
1048
+ query: {
1049
+ pattern: raw.query.pattern,
1050
+ caseSensitive: raw.query.case_sensitive,
1051
+ },
1052
+ repo: {
1053
+ ref: raw.repo.ref,
1054
+ commit: raw.repo.commit,
1055
+ },
1056
+ matches: raw.matches.map(transformGrepFileMatch),
1057
+ nextCursor: raw.next_cursor ?? undefined,
1058
+ hasMore: raw.has_more,
1059
+ };
1060
+ }
1061
+
1062
+ async pullUpstream(options: PullUpstreamOptions = {}): Promise<void> {
1063
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1064
+ const jwt = await this.generateJWT(this.id, {
1065
+ permissions: ['git:write'],
1066
+ ttl,
1067
+ });
1068
+
1069
+ const body: Record<string, string> = {};
1070
+
1071
+ if (options.ref) {
1072
+ body.ref = options.ref;
1073
+ }
1074
+
1075
+ const response = await this.api.post(
1076
+ { path: 'repos/pull-upstream', body },
1077
+ jwt
1078
+ );
1079
+
1080
+ if (response.status !== 202) {
1081
+ throw new Error(
1082
+ `Pull Upstream failed: ${response.status} ${await response.text()}`
1083
+ );
1084
+ }
1085
+
1086
+ return;
1087
+ }
1088
+
1089
+ async createBranch(
1090
+ options: CreateBranchOptions
1091
+ ): Promise<CreateBranchResult> {
1092
+ const baseBranch = options?.baseBranch?.trim();
1093
+ if (!baseBranch) {
1094
+ throw new Error('createBranch baseBranch is required');
1095
+ }
1096
+ const targetBranch = options?.targetBranch?.trim();
1097
+ if (!targetBranch) {
1098
+ throw new Error('createBranch targetBranch is required');
1099
+ }
1100
+
1101
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1102
+ const jwt = await this.generateJWT(this.id, {
1103
+ permissions: ['git:write'],
1104
+ ttl,
1105
+ });
1106
+
1107
+ const body: Record<string, unknown> = {
1108
+ base_branch: baseBranch,
1109
+ target_branch: targetBranch,
1110
+ };
1111
+
1112
+ if (options.baseIsEphemeral === true) {
1113
+ body.base_is_ephemeral = true;
1114
+ }
1115
+ if (options.targetIsEphemeral === true) {
1116
+ body.target_is_ephemeral = true;
1117
+ }
1118
+
1119
+ const response = await this.api.post(
1120
+ { path: 'repos/branches/create', body },
1121
+ jwt
1122
+ );
1123
+ const raw = createBranchResponseSchema.parse(await response.json());
1124
+ return transformCreateBranchResult(raw);
1125
+ }
1126
+
1127
+ async restoreCommit(
1128
+ options: RestoreCommitOptions
1129
+ ): Promise<RestoreCommitResult> {
1130
+ const targetBranch = options?.targetBranch?.trim();
1131
+ if (!targetBranch) {
1132
+ throw new Error('restoreCommit targetBranch is required');
1133
+ }
1134
+ if (targetBranch.startsWith('refs/')) {
1135
+ throw new Error(
1136
+ 'restoreCommit targetBranch must not include refs/ prefix'
1137
+ );
1138
+ }
1139
+
1140
+ const targetCommitSha = options?.targetCommitSha?.trim();
1141
+ if (!targetCommitSha) {
1142
+ throw new Error('restoreCommit targetCommitSha is required');
1143
+ }
1144
+ const commitMessage = options?.commitMessage?.trim();
1145
+
1146
+ const authorName = options.author?.name?.trim();
1147
+ const authorEmail = options.author?.email?.trim();
1148
+ if (!authorName || !authorEmail) {
1149
+ throw new Error('restoreCommit author name and email are required');
1150
+ }
1151
+
1152
+ const ttl = resolveCommitTtlSeconds(options);
1153
+ const jwt = await this.generateJWT(this.id, {
1154
+ permissions: ['git:write'],
1155
+ ttl,
1156
+ });
1157
+
1158
+ const metadata: Record<string, unknown> = {
1159
+ target_branch: targetBranch,
1160
+ target_commit_sha: targetCommitSha,
1161
+ author: {
1162
+ name: authorName,
1163
+ email: authorEmail,
1164
+ },
1165
+ };
1166
+
1167
+ if (commitMessage) {
1168
+ metadata.commit_message = commitMessage;
1169
+ }
1170
+
1171
+ const expectedHeadSha = options.expectedHeadSha?.trim();
1172
+ if (expectedHeadSha) {
1173
+ metadata.expected_head_sha = expectedHeadSha;
1174
+ }
1175
+
1176
+ if (options.committer) {
1177
+ const committerName = options.committer.name?.trim();
1178
+ const committerEmail = options.committer.email?.trim();
1179
+ if (!committerName || !committerEmail) {
1180
+ throw new Error(
1181
+ 'restoreCommit committer name and email are required when provided'
1182
+ );
1183
+ }
1184
+ metadata.committer = {
1185
+ name: committerName,
1186
+ email: committerEmail,
1187
+ };
1188
+ }
1189
+
1190
+ const response = await this.api.post(
1191
+ { path: 'repos/restore-commit', body: { metadata } },
1192
+ jwt,
1193
+ {
1194
+ allowedStatus: [...RESTORE_COMMIT_ALLOWED_STATUS],
1195
+ }
1196
+ );
1197
+
1198
+ const payload = await response.json();
1199
+ const parsed = parseRestoreCommitPayload(payload);
1200
+ if (parsed && 'ack' in parsed) {
1201
+ return buildRestoreCommitResult(parsed.ack);
1202
+ }
1203
+
1204
+ const failure = parsed && 'failure' in parsed ? parsed.failure : undefined;
1205
+ const status =
1206
+ failure?.status ?? httpStatusToRestoreStatus(response.status);
1207
+ const message =
1208
+ failure?.message ??
1209
+ `Restore commit failed with HTTP ${response.status}` +
1210
+ (response.statusText ? ` ${response.statusText}` : '');
1211
+
1212
+ throw new RefUpdateError(message, {
1213
+ status,
1214
+ refUpdate: failure?.refUpdate,
1215
+ });
1216
+ }
1217
+
1218
+ createCommit(options: CreateCommitOptions): CommitBuilder {
1219
+ const version = this.options.apiVersion ?? API_VERSION;
1220
+ const baseUrl =
1221
+ this.options.apiBaseUrl ??
1222
+ GitStorage.getDefaultAPIBaseUrl(this.options.name);
1223
+ const transport = new FetchCommitTransport({ baseUrl, version });
1224
+ const ttl = resolveCommitTtlSeconds(options);
1225
+ const builderOptions: CreateCommitOptions = {
1226
+ ...options,
1227
+ ttl,
1228
+ };
1229
+ const getAuthToken = () =>
1230
+ this.generateJWT(this.id, {
1231
+ permissions: ['git:write'],
1232
+ ttl,
1233
+ });
1234
+
1235
+ return createCommitBuilder({
1236
+ options: builderOptions,
1237
+ getAuthToken,
1238
+ transport,
1239
+ });
1240
+ }
1241
+
1242
+ async createCommitFromDiff(
1243
+ options: CreateCommitFromDiffOptions
1244
+ ): Promise<CommitResult> {
1245
+ const version = this.options.apiVersion ?? API_VERSION;
1246
+ const baseUrl =
1247
+ this.options.apiBaseUrl ??
1248
+ GitStorage.getDefaultAPIBaseUrl(this.options.name);
1249
+ const transport = new FetchDiffCommitTransport({ baseUrl, version });
1250
+ const ttl = resolveCommitTtlSeconds(options);
1251
+ const requestOptions: CreateCommitFromDiffOptions = {
1252
+ ...options,
1253
+ ttl,
1254
+ };
1255
+ const getAuthToken = () =>
1256
+ this.generateJWT(this.id, {
1257
+ permissions: ['git:write'],
1258
+ ttl,
1259
+ });
1260
+
1261
+ return sendCommitFromDiff({
1262
+ options: requestOptions,
1263
+ getAuthToken,
1264
+ transport,
1265
+ });
1266
+ }
1135
1267
  }
1136
1268
 
1137
1269
  export class GitStorage {
1138
- private options: GitStorageOptions;
1139
- private api: ApiFetcher;
1140
-
1141
- constructor(options: GitStorageOptions) {
1142
- if (
1143
- !options ||
1144
- options.name === undefined ||
1145
- options.key === undefined ||
1146
- options.name === null ||
1147
- options.key === null
1148
- ) {
1149
- throw new Error(
1150
- 'GitStorage requires a name and key. Please check your configuration and try again.',
1151
- );
1152
- }
1153
-
1154
- if (typeof options.name !== 'string' || options.name.trim() === '') {
1155
- throw new Error('GitStorage name must be a non-empty string.');
1156
- }
1157
-
1158
- if (typeof options.key !== 'string' || options.key.trim() === '') {
1159
- throw new Error('GitStorage key must be a non-empty string.');
1160
- }
1161
-
1162
- const resolvedApiBaseUrl = options.apiBaseUrl ?? GitStorage.getDefaultAPIBaseUrl(options.name);
1163
- const resolvedApiVersion = options.apiVersion ?? API_VERSION;
1164
- const resolvedStorageBaseUrl =
1165
- options.storageBaseUrl ?? GitStorage.getDefaultStorageBaseUrl(options.name);
1166
- const resolvedDefaultTtl = options.defaultTTL;
1167
-
1168
- this.api = getApiInstance(resolvedApiBaseUrl, resolvedApiVersion);
1169
-
1170
- this.options = {
1171
- key: options.key,
1172
- name: options.name,
1173
- apiBaseUrl: resolvedApiBaseUrl,
1174
- apiVersion: resolvedApiVersion,
1175
- storageBaseUrl: resolvedStorageBaseUrl,
1176
- defaultTTL: resolvedDefaultTtl,
1177
- };
1178
- }
1179
-
1180
- static getDefaultAPIBaseUrl(name: string): string {
1181
- return API_BASE_URL.replace('{{org}}', name);
1182
- }
1183
-
1184
- static getDefaultStorageBaseUrl(name: string): string {
1185
- return STORAGE_BASE_URL.replace('{{org}}', name);
1186
- }
1187
-
1188
- /**
1189
- * Create a new repository
1190
- * @returns The created repository
1191
- */
1192
- async createRepo(options?: CreateRepoOptions): Promise<Repo> {
1193
- const repoId = options?.id || crypto.randomUUID();
1194
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1195
- const jwt = await this.generateJWT(repoId, {
1196
- permissions: ['repo:write'],
1197
- ttl,
1198
- });
1199
-
1200
- const baseRepo = options?.baseRepo;
1201
- const isFork = baseRepo ? 'id' in baseRepo : false;
1202
- let baseRepoOptions: Record<string, unknown> | null = null;
1203
- let resolvedDefaultBranch: string | undefined;
1204
-
1205
- if (baseRepo) {
1206
- if ('id' in baseRepo) {
1207
- const baseRepoToken = await this.generateJWT(`${this.options.name}/${baseRepo.id}`, {
1208
- permissions: ['git:read'],
1209
- ttl,
1210
- });
1211
- baseRepoOptions = {
1212
- provider: 'code',
1213
- owner: this.options.name,
1214
- name: baseRepo.id,
1215
- operation: 'fork',
1216
- auth: { token: baseRepoToken },
1217
- ...(baseRepo.ref ? { ref: baseRepo.ref } : {}),
1218
- ...(baseRepo.sha ? { sha: baseRepo.sha } : {}),
1219
- };
1220
- } else {
1221
- baseRepoOptions = {
1222
- provider: 'github',
1223
- ...snakecaseKeys(baseRepo as unknown as Record<string, unknown>),
1224
- };
1225
- resolvedDefaultBranch = baseRepo.defaultBranch;
1226
- }
1227
- }
1228
-
1229
- // Match backend priority: baseRepo.defaultBranch > options.defaultBranch > 'main'
1230
- if (!resolvedDefaultBranch) {
1231
- if (options?.defaultBranch) {
1232
- resolvedDefaultBranch = options.defaultBranch;
1233
- } else if (!isFork) {
1234
- resolvedDefaultBranch = 'main';
1235
- }
1236
- }
1237
-
1238
- const createRepoPath =
1239
- baseRepoOptions || resolvedDefaultBranch
1240
- ? {
1241
- path: 'repos',
1242
- body: {
1243
- ...(baseRepoOptions && { base_repo: baseRepoOptions }),
1244
- ...(resolvedDefaultBranch && { default_branch: resolvedDefaultBranch }),
1245
- },
1246
- }
1247
- : 'repos';
1248
-
1249
- // Allow 409 so we can map it to a clearer error message
1250
- const resp = await this.api.post(createRepoPath, jwt, { allowedStatus: [409] });
1251
- if (resp.status === 409) {
1252
- throw new Error('Repository already exists');
1253
- }
1254
-
1255
- return new RepoImpl(
1256
- repoId,
1257
- resolvedDefaultBranch ?? 'main',
1258
- this.options,
1259
- this.generateJWT.bind(this),
1260
- );
1261
- }
1262
-
1263
- /**
1264
- * List repositories for the authenticated organization
1265
- * @returns Paginated repositories list
1266
- */
1267
- async listRepos(options?: ListReposOptions): Promise<ListReposResult> {
1268
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1269
- const jwt = await this.generateJWT('org', {
1270
- permissions: ['org:read'],
1271
- ttl,
1272
- });
1273
-
1274
- let params: Record<string, string> | undefined;
1275
- if (options?.cursor || typeof options?.limit === 'number') {
1276
- params = {};
1277
- if (options.cursor) {
1278
- params.cursor = options.cursor;
1279
- }
1280
- if (typeof options.limit === 'number') {
1281
- params.limit = options.limit.toString();
1282
- }
1283
- }
1284
-
1285
- const response = await this.api.get({ path: 'repos', params }, jwt);
1286
- const raw = listReposResponseSchema.parse(await response.json());
1287
- return transformListReposResult({
1288
- ...raw,
1289
- next_cursor: raw.next_cursor ?? undefined,
1290
- });
1291
- }
1292
-
1293
- /**
1294
- * Find a repository by ID
1295
- * @param options The search options
1296
- * @returns The found repository
1297
- */
1298
- async findOne(options: FindOneOptions): Promise<Repo | null> {
1299
- const jwt = await this.generateJWT(options.id, {
1300
- permissions: ['git:read'],
1301
- ttl: DEFAULT_TOKEN_TTL_SECONDS,
1302
- });
1303
-
1304
- // Allow 404 to indicate "not found" without throwing
1305
- const resp = await this.api.get('repo', jwt, { allowedStatus: [404] });
1306
- if (resp.status === 404) {
1307
- return null;
1308
- }
1309
- const body = (await resp.json()) as { default_branch?: string };
1310
- const defaultBranch = body.default_branch ?? 'main';
1311
- return new RepoImpl(options.id, defaultBranch, this.options, this.generateJWT.bind(this));
1312
- }
1313
-
1314
- /**
1315
- * Delete a repository by ID
1316
- * @param options The delete options containing the repo ID
1317
- * @returns The deletion result
1318
- */
1319
- async deleteRepo(options: DeleteRepoOptions): Promise<DeleteRepoResult> {
1320
- const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1321
- const jwt = await this.generateJWT(options.id, {
1322
- permissions: ['repo:write'],
1323
- ttl,
1324
- });
1325
-
1326
- // Allow 404 and 409 for clearer error handling
1327
- const resp = await this.api.delete('repos/delete', jwt, { allowedStatus: [404, 409] });
1328
- if (resp.status === 404) {
1329
- throw new Error('Repository not found');
1330
- }
1331
- if (resp.status === 409) {
1332
- throw new Error('Repository already deleted');
1333
- }
1334
-
1335
- const body = (await resp.json()) as { repo_id: string; message: string };
1336
- return {
1337
- repoId: body.repo_id,
1338
- message: body.message,
1339
- };
1340
- }
1341
-
1342
- /**
1343
- * Get the current configuration
1344
- * @returns The client configuration
1345
- */
1346
- getConfig(): GitStorageOptions {
1347
- return { ...this.options };
1348
- }
1349
-
1350
- /**
1351
- * Generate a JWT token for git storage URL authentication
1352
- * @private
1353
- */
1354
- private async generateJWT(repoId: string, options?: GetRemoteURLOptions): Promise<string> {
1355
- // Default permissions and TTL
1356
- const permissions = options?.permissions || ['git:write', 'git:read'];
1357
- const ttl = resolveInvocationTtlSeconds(options, this.options.defaultTTL ?? 365 * 24 * 60 * 60);
1358
-
1359
- // Create the JWT payload
1360
- const now = Math.floor(Date.now() / 1000);
1361
- const payload = {
1362
- iss: this.options.name,
1363
- sub: '@pierre/storage',
1364
- repo: repoId,
1365
- scopes: permissions,
1366
- iat: now,
1367
- exp: now + ttl,
1368
- };
1369
-
1370
- // Sign the JWT with the key as the secret
1371
- // Using HS256 for symmetric signing with the key
1372
- const key = await importPKCS8(this.options.key, 'ES256');
1373
- // Sign the JWT with the key as the secret
1374
- const jwt = await new SignJWT(payload)
1375
- .setProtectedHeader({ alg: 'ES256', typ: 'JWT' })
1376
- .sign(key);
1377
-
1378
- return jwt;
1379
- }
1270
+ private options: GitStorageOptions;
1271
+ private api: ApiFetcher;
1272
+
1273
+ constructor(options: GitStorageOptions) {
1274
+ if (
1275
+ !options ||
1276
+ options.name === undefined ||
1277
+ options.key === undefined ||
1278
+ options.name === null ||
1279
+ options.key === null
1280
+ ) {
1281
+ throw new Error(
1282
+ 'GitStorage requires a name and key. Please check your configuration and try again.'
1283
+ );
1284
+ }
1285
+
1286
+ if (typeof options.name !== 'string' || options.name.trim() === '') {
1287
+ throw new Error('GitStorage name must be a non-empty string.');
1288
+ }
1289
+
1290
+ if (typeof options.key !== 'string' || options.key.trim() === '') {
1291
+ throw new Error('GitStorage key must be a non-empty string.');
1292
+ }
1293
+
1294
+ const resolvedApiBaseUrl =
1295
+ options.apiBaseUrl ?? GitStorage.getDefaultAPIBaseUrl(options.name);
1296
+ const resolvedApiVersion = options.apiVersion ?? API_VERSION;
1297
+ const resolvedStorageBaseUrl =
1298
+ options.storageBaseUrl ??
1299
+ GitStorage.getDefaultStorageBaseUrl(options.name);
1300
+ const resolvedDefaultTtl = options.defaultTTL;
1301
+
1302
+ this.api = getApiInstance(resolvedApiBaseUrl, resolvedApiVersion);
1303
+
1304
+ this.options = {
1305
+ key: options.key,
1306
+ name: options.name,
1307
+ apiBaseUrl: resolvedApiBaseUrl,
1308
+ apiVersion: resolvedApiVersion,
1309
+ storageBaseUrl: resolvedStorageBaseUrl,
1310
+ defaultTTL: resolvedDefaultTtl,
1311
+ };
1312
+ }
1313
+
1314
+ static getDefaultAPIBaseUrl(name: string): string {
1315
+ return API_BASE_URL.replace('{{org}}', name);
1316
+ }
1317
+
1318
+ static getDefaultStorageBaseUrl(name: string): string {
1319
+ return STORAGE_BASE_URL.replace('{{org}}', name);
1320
+ }
1321
+
1322
+ /**
1323
+ * Create a new repository
1324
+ * @returns The created repository
1325
+ */
1326
+ async createRepo(options?: CreateRepoOptions): Promise<Repo> {
1327
+ const repoId = options?.id || crypto.randomUUID();
1328
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1329
+ const jwt = await this.generateJWT(repoId, {
1330
+ permissions: ['repo:write'],
1331
+ ttl,
1332
+ });
1333
+
1334
+ const baseRepo = options?.baseRepo;
1335
+ const isFork = baseRepo ? 'id' in baseRepo : false;
1336
+ let baseRepoOptions: Record<string, unknown> | null = null;
1337
+ let resolvedDefaultBranch: string | undefined;
1338
+
1339
+ if (baseRepo) {
1340
+ if ('id' in baseRepo) {
1341
+ const baseRepoToken = await this.generateJWT(baseRepo.id, {
1342
+ permissions: ['git:read'],
1343
+ ttl,
1344
+ });
1345
+ baseRepoOptions = {
1346
+ provider: 'code',
1347
+ owner: this.options.name,
1348
+ name: baseRepo.id,
1349
+ operation: 'fork',
1350
+ auth: { token: baseRepoToken },
1351
+ ...(baseRepo.ref ? { ref: baseRepo.ref } : {}),
1352
+ ...(baseRepo.sha ? { sha: baseRepo.sha } : {}),
1353
+ };
1354
+ } else {
1355
+ baseRepoOptions = {
1356
+ provider: 'github',
1357
+ ...snakecaseKeys(baseRepo as unknown as Record<string, unknown>),
1358
+ };
1359
+ resolvedDefaultBranch = baseRepo.defaultBranch;
1360
+ }
1361
+ }
1362
+
1363
+ // Match backend priority: baseRepo.defaultBranch > options.defaultBranch > 'main'
1364
+ if (!resolvedDefaultBranch) {
1365
+ if (options?.defaultBranch) {
1366
+ resolvedDefaultBranch = options.defaultBranch;
1367
+ } else if (!isFork) {
1368
+ resolvedDefaultBranch = 'main';
1369
+ }
1370
+ }
1371
+
1372
+ const createRepoPath =
1373
+ baseRepoOptions || resolvedDefaultBranch
1374
+ ? {
1375
+ path: 'repos',
1376
+ body: {
1377
+ ...(baseRepoOptions && { base_repo: baseRepoOptions }),
1378
+ ...(resolvedDefaultBranch && {
1379
+ default_branch: resolvedDefaultBranch,
1380
+ }),
1381
+ },
1382
+ }
1383
+ : 'repos';
1384
+
1385
+ // Allow 409 so we can map it to a clearer error message
1386
+ const resp = await this.api.post(createRepoPath, jwt, {
1387
+ allowedStatus: [409],
1388
+ });
1389
+ if (resp.status === 409) {
1390
+ throw new Error('Repository already exists');
1391
+ }
1392
+
1393
+ return new RepoImpl(
1394
+ repoId,
1395
+ resolvedDefaultBranch ?? 'main',
1396
+ this.options,
1397
+ this.generateJWT.bind(this)
1398
+ );
1399
+ }
1400
+
1401
+ /**
1402
+ * List repositories for the authenticated organization
1403
+ * @returns Paginated repositories list
1404
+ */
1405
+ async listRepos(options?: ListReposOptions): Promise<ListReposResult> {
1406
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1407
+ const jwt = await this.generateJWT('org', {
1408
+ permissions: ['org:read'],
1409
+ ttl,
1410
+ });
1411
+
1412
+ let params: Record<string, string> | undefined;
1413
+ if (options?.cursor || typeof options?.limit === 'number') {
1414
+ params = {};
1415
+ if (options.cursor) {
1416
+ params.cursor = options.cursor;
1417
+ }
1418
+ if (typeof options.limit === 'number') {
1419
+ params.limit = options.limit.toString();
1420
+ }
1421
+ }
1422
+
1423
+ const response = await this.api.get({ path: 'repos', params }, jwt);
1424
+ const raw = listReposResponseSchema.parse(await response.json());
1425
+ return transformListReposResult({
1426
+ ...raw,
1427
+ next_cursor: raw.next_cursor ?? undefined,
1428
+ });
1429
+ }
1430
+
1431
+ /**
1432
+ * Find a repository by ID
1433
+ * @param options The search options
1434
+ * @returns The found repository
1435
+ */
1436
+ async findOne(options: FindOneOptions): Promise<Repo | null> {
1437
+ const jwt = await this.generateJWT(options.id, {
1438
+ permissions: ['git:read'],
1439
+ ttl: DEFAULT_TOKEN_TTL_SECONDS,
1440
+ });
1441
+
1442
+ // Allow 404 to indicate "not found" without throwing
1443
+ const resp = await this.api.get('repo', jwt, { allowedStatus: [404] });
1444
+ if (resp.status === 404) {
1445
+ return null;
1446
+ }
1447
+ const body = (await resp.json()) as { default_branch?: string };
1448
+ const defaultBranch = body.default_branch ?? 'main';
1449
+ return new RepoImpl(
1450
+ options.id,
1451
+ defaultBranch,
1452
+ this.options,
1453
+ this.generateJWT.bind(this)
1454
+ );
1455
+ }
1456
+
1457
+ /**
1458
+ * Delete a repository by ID
1459
+ * @param options The delete options containing the repo ID
1460
+ * @returns The deletion result
1461
+ */
1462
+ async deleteRepo(options: DeleteRepoOptions): Promise<DeleteRepoResult> {
1463
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1464
+ const jwt = await this.generateJWT(options.id, {
1465
+ permissions: ['repo:write'],
1466
+ ttl,
1467
+ });
1468
+
1469
+ // Allow 404 and 409 for clearer error handling
1470
+ const resp = await this.api.delete('repos/delete', jwt, {
1471
+ allowedStatus: [404, 409],
1472
+ });
1473
+ if (resp.status === 404) {
1474
+ throw new Error('Repository not found');
1475
+ }
1476
+ if (resp.status === 409) {
1477
+ throw new Error('Repository already deleted');
1478
+ }
1479
+
1480
+ const body = (await resp.json()) as { repo_id: string; message: string };
1481
+ return {
1482
+ repoId: body.repo_id,
1483
+ message: body.message,
1484
+ };
1485
+ }
1486
+
1487
+ /**
1488
+ * Get the current configuration
1489
+ * @returns The client configuration
1490
+ */
1491
+ getConfig(): GitStorageOptions {
1492
+ return { ...this.options };
1493
+ }
1494
+
1495
+ /**
1496
+ * Generate a JWT token for git storage URL authentication
1497
+ * @private
1498
+ */
1499
+ private async generateJWT(
1500
+ repoId: string,
1501
+ options?: GetRemoteURLOptions
1502
+ ): Promise<string> {
1503
+ // Default permissions and TTL
1504
+ const permissions = options?.permissions || ['git:write', 'git:read'];
1505
+ const ttl = resolveInvocationTtlSeconds(
1506
+ options,
1507
+ this.options.defaultTTL ?? 365 * 24 * 60 * 60
1508
+ );
1509
+
1510
+ // Create the JWT payload
1511
+ const now = Math.floor(Date.now() / 1000);
1512
+ const payload = {
1513
+ iss: this.options.name,
1514
+ sub: '@pierre/storage',
1515
+ repo: repoId,
1516
+ scopes: permissions,
1517
+ iat: now,
1518
+ exp: now + ttl,
1519
+ };
1520
+
1521
+ // Sign the JWT with the key as the secret
1522
+ // Using HS256 for symmetric signing with the key
1523
+ const key = await importPKCS8(this.options.key, 'ES256');
1524
+ // Sign the JWT with the key as the secret
1525
+ const jwt = await new SignJWT(payload)
1526
+ .setProtectedHeader({ alg: 'ES256', typ: 'JWT' })
1527
+ .sign(key);
1528
+
1529
+ return jwt;
1530
+ }
1380
1531
  }
1381
1532
 
1382
1533
  // Export a default client factory
1383
1534
  export function createClient(options: GitStorageOptions): GitStorage {
1384
- return new GitStorage(options);
1535
+ return new GitStorage(options);
1385
1536
  }
1386
1537
 
1387
1538
  // Export CodeStorage as an alias for GitStorage