@pierre/storage 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pierre/storage",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pierre Git Storage SDK",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -9,20 +9,25 @@ import snakecaseKeys from 'snakecase-keys';
9
9
  import { createCommitBuilder, FetchCommitTransport, resolveCommitTtlSeconds } from './commit';
10
10
  import { FetchDiffCommitTransport, sendCommitFromDiff } from './diff-commit';
11
11
  import { RefUpdateError } from './errors';
12
- import { ApiFetcher } from './fetch';
12
+ import { ApiError, ApiFetcher } from './fetch';
13
13
  import type { RestoreCommitAckRaw } from './schemas';
14
14
  import {
15
15
  branchDiffResponseSchema,
16
16
  commitDiffResponseSchema,
17
17
  createBranchResponseSchema,
18
+ errorEnvelopeSchema,
18
19
  grepResponseSchema,
19
20
  listBranchesResponseSchema,
20
21
  listCommitsResponseSchema,
21
22
  listFilesResponseSchema,
23
+ listReposResponseSchema,
24
+ noteReadResponseSchema,
25
+ noteWriteResponseSchema,
22
26
  restoreCommitAckSchema,
23
27
  restoreCommitResponseSchema,
24
28
  } from './schemas';
25
29
  import type {
30
+ AppendNoteOptions,
26
31
  BranchInfo,
27
32
  CommitBuilder,
28
33
  CommitInfo,
@@ -32,7 +37,9 @@ import type {
32
37
  CreateBranchResult,
33
38
  CreateCommitFromDiffOptions,
34
39
  CreateCommitOptions,
40
+ CreateNoteOptions,
35
41
  CreateRepoOptions,
42
+ DeleteNoteOptions,
36
43
  DeleteRepoOptions,
37
44
  DeleteRepoResult,
38
45
  DiffFileState,
@@ -46,6 +53,8 @@ import type {
46
53
  GetCommitDiffResponse,
47
54
  GetCommitDiffResult,
48
55
  GetFileOptions,
56
+ GetNoteOptions,
57
+ GetNoteResult,
49
58
  GetRemoteURLOptions,
50
59
  GitStorageOptions,
51
60
  GrepFileMatch,
@@ -60,6 +69,10 @@ import type {
60
69
  ListCommitsResult,
61
70
  ListFilesOptions,
62
71
  ListFilesResult,
72
+ ListReposOptions,
73
+ ListReposResponse,
74
+ ListReposResult,
75
+ NoteWriteResult,
63
76
  PullUpstreamOptions,
64
77
  RawBranchInfo,
65
78
  RawCommitInfo,
@@ -114,6 +127,23 @@ const RESTORE_COMMIT_ALLOWED_STATUS = [
114
127
  504, // Gateway Timeout - long-running storage operations
115
128
  ] as const;
116
129
 
130
+ 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
145
+ ] as const;
146
+
117
147
  function resolveInvocationTtlSeconds(
118
148
  options?: { ttl?: number },
119
149
  defaultValue: number = DEFAULT_TOKEN_TTL_SECONDS,
@@ -337,6 +367,26 @@ function transformCreateBranchResult(raw: CreateBranchResponse): CreateBranchRes
337
367
  };
338
368
  }
339
369
 
370
+ 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
+ };
388
+ }
389
+
340
390
  function transformGrepLine(raw: { line_number: number; text: string; type: string }): GrepLine {
341
391
  return {
342
392
  lineNumber: raw.line_number,
@@ -355,6 +405,119 @@ function transformGrepFileMatch(raw: {
355
405
  };
356
406
  }
357
407
 
408
+ function transformNoteReadResult(raw: {
409
+ sha: string;
410
+ note: string;
411
+ ref_sha: string;
412
+ }): GetNoteResult {
413
+ return {
414
+ sha: raw.sha,
415
+ note: raw.note,
416
+ refSha: raw.ref_sha,
417
+ };
418
+ }
419
+
420
+ 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 };
426
+ }): 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
+ };
438
+ }
439
+
440
+ function buildNoteWriteBody(
441
+ sha: string,
442
+ note: string,
443
+ action: 'add' | 'append',
444
+ options: { expectedRefSha?: string; author?: { name: string; email: string } },
445
+ ): 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;
470
+ }
471
+
472
+ async function parseNoteWriteResponse(
473
+ response: Response,
474
+ method: 'POST' | 'DELETE',
475
+ ): 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
+ });
519
+ }
520
+
358
521
  /**
359
522
  * Implementation of the Repo interface
360
523
  */
@@ -500,6 +663,154 @@ class RepoImpl implements Repo {
500
663
  });
501
664
  }
502
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
+
503
814
  async getBranchDiff(options: GetBranchDiffOptions): Promise<GetBranchDiffResult> {
504
815
  const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
505
816
  const jwt = await this.generateJWT(this.id, {
@@ -916,6 +1227,36 @@ export class GitStorage {
916
1227
  return new RepoImpl(repoId, defaultBranch, this.options, this.generateJWT.bind(this));
917
1228
  }
918
1229
 
1230
+ /**
1231
+ * List repositories for the authenticated organization
1232
+ * @returns Paginated repositories list
1233
+ */
1234
+ async listRepos(options?: ListReposOptions): Promise<ListReposResult> {
1235
+ const ttl = resolveInvocationTtlSeconds(options, DEFAULT_TOKEN_TTL_SECONDS);
1236
+ const jwt = await this.generateJWT('org', {
1237
+ permissions: ['org:read'],
1238
+ ttl,
1239
+ });
1240
+
1241
+ let params: Record<string, string> | undefined;
1242
+ if (options?.cursor || typeof options?.limit === 'number') {
1243
+ params = {};
1244
+ if (options.cursor) {
1245
+ params.cursor = options.cursor;
1246
+ }
1247
+ if (typeof options.limit === 'number') {
1248
+ params.limit = options.limit.toString();
1249
+ }
1250
+ }
1251
+
1252
+ const response = await this.api.get({ path: 'repos', params }, jwt);
1253
+ const raw = listReposResponseSchema.parse(await response.json());
1254
+ return transformListReposResult({
1255
+ ...raw,
1256
+ next_cursor: raw.next_cursor ?? undefined,
1257
+ });
1258
+ }
1259
+
919
1260
  /**
920
1261
  * Find a repository by ID
921
1262
  * @param options The search options
package/src/schemas.ts CHANGED
@@ -34,6 +34,46 @@ export const listCommitsResponseSchema = z.object({
34
34
  has_more: z.boolean(),
35
35
  });
36
36
 
37
+ export const repoBaseInfoSchema = z.object({
38
+ provider: z.string(),
39
+ owner: z.string(),
40
+ name: z.string(),
41
+ });
42
+
43
+ export const repoInfoSchema = z.object({
44
+ repo_id: z.string(),
45
+ url: z.string(),
46
+ default_branch: z.string(),
47
+ created_at: z.string(),
48
+ base_repo: repoBaseInfoSchema.optional().nullable(),
49
+ });
50
+
51
+ export const listReposResponseSchema = z.object({
52
+ repos: z.array(repoInfoSchema),
53
+ next_cursor: z.string().nullable().optional(),
54
+ has_more: z.boolean(),
55
+ });
56
+
57
+ export const noteReadResponseSchema = z.object({
58
+ sha: z.string(),
59
+ note: z.string(),
60
+ ref_sha: z.string(),
61
+ });
62
+
63
+ export const noteResultSchema = z.object({
64
+ success: z.boolean(),
65
+ status: z.string(),
66
+ message: z.string().optional(),
67
+ });
68
+
69
+ export const noteWriteResponseSchema = z.object({
70
+ sha: z.string(),
71
+ target_ref: z.string(),
72
+ base_commit: z.string().optional(),
73
+ new_ref_sha: z.string(),
74
+ result: noteResultSchema,
75
+ });
76
+
37
77
  export const diffStatsSchema = z.object({
38
78
  files: z.number(),
39
79
  additions: z.number(),
@@ -162,6 +202,11 @@ export type RawBranchInfo = z.infer<typeof branchInfoSchema>;
162
202
  export type ListBranchesResponseRaw = z.infer<typeof listBranchesResponseSchema>;
163
203
  export type RawCommitInfo = z.infer<typeof commitInfoRawSchema>;
164
204
  export type ListCommitsResponseRaw = z.infer<typeof listCommitsResponseSchema>;
205
+ export type RawRepoBaseInfo = z.infer<typeof repoBaseInfoSchema>;
206
+ export type RawRepoInfo = z.infer<typeof repoInfoSchema>;
207
+ export type ListReposResponseRaw = z.infer<typeof listReposResponseSchema>;
208
+ export type NoteReadResponseRaw = z.infer<typeof noteReadResponseSchema>;
209
+ export type NoteWriteResponseRaw = z.infer<typeof noteWriteResponseSchema>;
165
210
  export type RawFileDiff = z.infer<typeof diffFileRawSchema>;
166
211
  export type RawFilteredFile = z.infer<typeof filteredFileRawSchema>;
167
212
  export type GetBranchDiffResponseRaw = z.infer<typeof branchDiffResponseSchema>;
package/src/types.ts CHANGED
@@ -9,10 +9,15 @@ import type {
9
9
  ListBranchesResponseRaw,
10
10
  ListCommitsResponseRaw,
11
11
  ListFilesResponseRaw,
12
+ ListReposResponseRaw,
13
+ NoteReadResponseRaw,
14
+ NoteWriteResponseRaw,
12
15
  RawBranchInfo as SchemaRawBranchInfo,
13
16
  RawCommitInfo as SchemaRawCommitInfo,
14
17
  RawFileDiff as SchemaRawFileDiff,
15
18
  RawFilteredFile as SchemaRawFilteredFile,
19
+ RawRepoBaseInfo as SchemaRawRepoBaseInfo,
20
+ RawRepoInfo as SchemaRawRepoInfo,
16
21
  } from './schemas';
17
22
 
18
23
  export interface OverrideableGitStorageOptions {
@@ -31,7 +36,7 @@ export interface GitStorageOptions extends OverrideableGitStorageOptions {
31
36
  export type ValidAPIVersion = 1;
32
37
 
33
38
  export interface GetRemoteURLOptions {
34
- permissions?: ('git:write' | 'git:read' | 'repo:write')[];
39
+ permissions?: ('git:write' | 'git:read' | 'repo:write' | 'org:read')[];
35
40
  ttl?: number;
36
41
  }
37
42
 
@@ -45,6 +50,10 @@ export interface Repo {
45
50
  listFiles(options?: ListFilesOptions): Promise<ListFilesResult>;
46
51
  listBranches(options?: ListBranchesOptions): Promise<ListBranchesResult>;
47
52
  listCommits(options?: ListCommitsOptions): Promise<ListCommitsResult>;
53
+ getNote(options: GetNoteOptions): Promise<GetNoteResult>;
54
+ createNote(options: CreateNoteOptions): Promise<NoteWriteResult>;
55
+ appendNote(options: AppendNoteOptions): Promise<NoteWriteResult>;
56
+ deleteNote(options: DeleteNoteOptions): Promise<NoteWriteResult>;
48
57
  getBranchDiff(options: GetBranchDiffOptions): Promise<GetBranchDiffResult>;
49
58
  getCommitDiff(options: GetCommitDiffOptions): Promise<GetCommitDiffResult>;
50
59
  grep(options: GrepOptions): Promise<GrepResult>;
@@ -84,6 +93,37 @@ export interface BaseRepo {
84
93
  defaultBranch?: string;
85
94
  }
86
95
 
96
+ export interface ListReposOptions extends GitStorageInvocationOptions {
97
+ cursor?: string;
98
+ limit?: number;
99
+ }
100
+
101
+ export type RawRepoBaseInfo = SchemaRawRepoBaseInfo;
102
+
103
+ export interface RepoBaseInfo {
104
+ provider: string;
105
+ owner: string;
106
+ name: string;
107
+ }
108
+
109
+ export type RawRepoInfo = SchemaRawRepoInfo;
110
+
111
+ export interface RepoInfo {
112
+ repoId: string;
113
+ url: string;
114
+ defaultBranch: string;
115
+ createdAt: string;
116
+ baseRepo?: RepoBaseInfo;
117
+ }
118
+
119
+ export type ListReposResponse = ListReposResponseRaw;
120
+
121
+ export interface ListReposResult {
122
+ repos: RepoInfo[];
123
+ nextCursor?: string;
124
+ hasMore: boolean;
125
+ }
126
+
87
127
  export interface CreateRepoOptions extends GitStorageInvocationOptions {
88
128
  id?: string;
89
129
  baseRepo?: BaseRepo;
@@ -192,6 +232,52 @@ export interface ListCommitsResult {
192
232
  hasMore: boolean;
193
233
  }
194
234
 
235
+ // Git notes API types
236
+ export interface GetNoteOptions extends GitStorageInvocationOptions {
237
+ sha: string;
238
+ }
239
+
240
+ export type GetNoteResponse = NoteReadResponseRaw;
241
+
242
+ export interface GetNoteResult {
243
+ sha: string;
244
+ note: string;
245
+ refSha: string;
246
+ }
247
+
248
+ interface NoteWriteBaseOptions extends GitStorageInvocationOptions {
249
+ sha: string;
250
+ note: string;
251
+ expectedRefSha?: string;
252
+ author?: CommitSignature;
253
+ }
254
+
255
+ export type CreateNoteOptions = NoteWriteBaseOptions;
256
+
257
+ export type AppendNoteOptions = NoteWriteBaseOptions;
258
+
259
+ export interface DeleteNoteOptions extends GitStorageInvocationOptions {
260
+ sha: string;
261
+ expectedRefSha?: string;
262
+ author?: CommitSignature;
263
+ }
264
+
265
+ export interface NoteWriteResultPayload {
266
+ success: boolean;
267
+ status: string;
268
+ message?: string;
269
+ }
270
+
271
+ export type NoteWriteResponse = NoteWriteResponseRaw;
272
+
273
+ export interface NoteWriteResult {
274
+ sha: string;
275
+ targetRef: string;
276
+ baseCommit?: string;
277
+ newRefSha: string;
278
+ result: NoteWriteResultPayload;
279
+ }
280
+
195
281
  // Branch Diff API types
196
282
  export interface GetBranchDiffOptions extends GitStorageInvocationOptions {
197
283
  branch: string;